PlanOpticon
Merge pull request #91 from ConflictHQ/feat/sqlite-migration feat: PlanOpticon v0.4.0 — planning agent, companion REPL, 20+ sources, SQLite
Commit
0981a082d9bd09a808b2fa13ef2ab23b8b463b0ddc0631c05429fb5c904577f7
Parent
f118ba0ec2eacc7…
113 files changed
+71
+1
-1
+105
-33
+83
-6
+41
-12
+561
-11
+47
-4
+80
+174
-1
+24
-4
+406
+27
-3
+413
-11
+627
+114
+309
+114
+252
+162
+224
+10
-25
+56
-42
+422
+275
+433
-2
+359
+286
-33
+1572
+286
+198
+339
+159
+98
+1
+33
+94
+65
+99
+76
+93
+420
+70
+74
+95
+68
+76
+315
+183
+485
+1480
-47
+414
+209
+1
+500
+49
+2
-2
+31
-4
+381
-201
+155
-13
+193
+70
-1
+4
-4
+54
-6
+23
+56
+88
+133
+77
+36
-2
+98
+36
-9
+38
+171
-2
+193
+20
+123
+56
-53
+24
+14
-1
+187
+171
+53
-56
+167
+10
-1
+12
-3
+138
+20
+226
+20
+59
-2
+178
+117
+156
+170
+268
+112
+200
+310
+280
+380
+178
+222
+119
+103
+114
+76
+375
+129
+90
+118
+399
+57
+200
~
CHANGELOG.md
~
CLAUDE.md
~
README.md
~
docs/architecture/overview.md
~
docs/architecture/providers.md
~
docs/cli-reference.md
~
docs/getting-started/configuration.md
~
docs/getting-started/quickstart.md
~
docs/guide/cloud-sources.md
~
docs/index.md
~
knowledge-base/viewer.html
~
pyproject.toml
~
tests/test_agent.py
~
tests/test_agent_skills.py
~
tests/test_api_spec.py
~
tests/test_auth.py
~
tests/test_callbacks.py
~
tests/test_cli.py
~
tests/test_companion.py
~
tests/test_exchange.py
~
tests/test_graph_query.py
~
tests/test_graph_store.py
~
tests/test_knowledge_graph.py
~
tests/test_output_formatter.py
~
tests/test_pipeline.py
~
tests/test_processors.py
~
tests/test_providers.py
~
tests/test_sources.py
~
tests/test_taxonomy.py
~
tests/test_usage_tracker.py
~
tests/test_visualization.py
~
video_processor/agent/agent_loop.py
~
video_processor/agent/kb_context.py
~
video_processor/agent/orchestrator.py
~
video_processor/agent/skills/__init__.py
~
video_processor/agent/skills/artifact_export.py
~
video_processor/agent/skills/base.py
~
video_processor/agent/skills/cli_adapter.py
~
video_processor/agent/skills/doc_generator.py
~
video_processor/agent/skills/github_integration.py
~
video_processor/agent/skills/notes_export.py
~
video_processor/agent/skills/prd.py
~
video_processor/agent/skills/project_plan.py
~
video_processor/agent/skills/requirements_chat.py
~
video_processor/agent/skills/roadmap.py
~
video_processor/agent/skills/task_breakdown.py
~
video_processor/agent/skills/wiki_generator.py
~
video_processor/api/openapi_spec.py
~
video_processor/auth.py
~
video_processor/cli/commands.py
~
video_processor/cli/companion.py
~
video_processor/exchange.py
~
video_processor/exporters/__init__.py
~
video_processor/exporters/markdown.py
~
video_processor/extractors/frame_extractor.py
~
video_processor/integrators/graph_discovery.py
~
video_processor/integrators/graph_query.py
~
video_processor/integrators/graph_store.py
~
video_processor/integrators/knowledge_graph.py
~
video_processor/integrators/taxonomy.py
~
video_processor/models.py
~
video_processor/output_structure.py
~
video_processor/pipeline.py
~
video_processor/processors/__init__.py
~
video_processor/processors/base.py
~
video_processor/processors/ingest.py
~
video_processor/processors/markdown_processor.py
~
video_processor/processors/pdf_processor.py
~
video_processor/providers/__init__.py
~
video_processor/providers/ai21_provider.py
~
video_processor/providers/anthropic_provider.py
~
video_processor/providers/azure_provider.py
~
video_processor/providers/base.py
~
video_processor/providers/bedrock_provider.py
~
video_processor/providers/cerebras_provider.py
~
video_processor/providers/cohere_provider.py
~
video_processor/providers/discovery.py
~
video_processor/providers/fireworks_provider.py
~
video_processor/providers/gemini_provider.py
~
video_processor/providers/huggingface_provider.py
~
video_processor/providers/litellm_provider.py
~
video_processor/providers/manager.py
~
video_processor/providers/mistral_provider.py
~
video_processor/providers/ollama_provider.py
~
video_processor/providers/openai_provider.py
~
video_processor/providers/qianfan_provider.py
~
video_processor/providers/together_provider.py
~
video_processor/providers/vertex_provider.py
~
video_processor/providers/xai_provider.py
~
video_processor/sources/__init__.py
~
video_processor/sources/apple_notes_source.py
~
video_processor/sources/arxiv_source.py
~
video_processor/sources/github_source.py
~
video_processor/sources/google_keep_source.py
~
video_processor/sources/gws_source.py
~
video_processor/sources/hackernews_source.py
~
video_processor/sources/logseq_source.py
~
video_processor/sources/m365_source.py
~
video_processor/sources/meet_recording_source.py
~
video_processor/sources/notion_source.py
~
video_processor/sources/obsidian_source.py
~
video_processor/sources/onenote_source.py
~
video_processor/sources/podcast_source.py
~
video_processor/sources/reddit_source.py
~
video_processor/sources/rss_source.py
~
video_processor/sources/s3_source.py
~
video_processor/sources/teams_recording_source.py
~
video_processor/sources/twitter_source.py
~
video_processor/sources/web_source.py
~
video_processor/sources/youtube_source.py
~
video_processor/sources/zoom_source.py
~
video_processor/utils/callbacks.py
~
video_processor/utils/visualization.py
+71
| --- a/CHANGELOG.md | ||
| +++ b/CHANGELOG.md | ||
| @@ -0,0 +1,71 @@ | ||
| 1 | +# Changelog | |
| 2 | + | |
| 3 | +All notable changes to PlanOpticon are documented in this file. | |
| 4 | + | |
| 5 | +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), | |
| 6 | +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). | |
| 7 | + | |
| 8 | +## [0.4.0] - 2026-03-07 | |
| 9 | + | |
| 10 | +### Added | |
| 11 | + | |
| 12 | +- **Planning agent framework** with 11 skills: project_plan, prd, roadmap, task_breakdown, github_integration, requirements_chat, doc_generator, artifact_export, cli_adapter, notes_export, wiki_generator. Invoke via `planopticon agent`. | |
| 13 | +- **Interactive companion REPL** (`planopticon companion` / `planopticon --chat`) with auto-discovery of knowledge graphs, videos, and documents in the workspace. 15 slash commands for graph exploration, ingestion, export, auth, and runtime provider/model switching. | |
| 14 | +- **20+ source connectors**: YouTube, Web, GitHub, Reddit, HackerNews, RSS, Podcast, Twitter/X, arXiv, S3, Google Workspace (Docs, Sheets, Slides), Microsoft 365 (SharePoint, OneDrive), Obsidian, Notion, Apple Notes, OneNote, Google Keep, Logseq, Zoom (OAuth), Teams, Google Meet. | |
| 15 | +- **Pluggable provider registry** supporting 15+ AI providers: OpenAI, Anthropic, Gemini, Ollama, Azure, Together, Fireworks, Cerebras, xAI, Bedrock, Vertex, Mistral, Cohere, AI21, HuggingFace, Qianfan, and LiteLLM. | |
| 16 | +- **Planning taxonomy classifier** for entity types: goal, requirement, risk, task, milestone, and other planning-specific categories. | |
| 17 | +- **Unified OAuth manager** (`planopticon auth`) with pre-built configs for Google, Dropbox, Zoom, Notion, GitHub, and Microsoft. Auth chain: saved token, OAuth PKCE, API key fallback. | |
| 18 | +- **Markdown document generator** producing 7 document types without an LLM: summary, meeting-notes, glossary, relationship-map, status-report, entity-index, csv. | |
| 19 | +- **Notes export** to Obsidian vaults (YAML frontmatter + wiki-links) and Notion-compatible markdown. | |
| 20 | +- **GitHub wiki generator** with direct push support. | |
| 21 | +- **PlanOpticonExchange** canonical JSON interchange format with merge and dedup. | |
| 22 | +- **Document ingestion pipeline** for PDF, Markdown, and plaintext sources. | |
| 23 | +- **Knowledge graph viewer** -- self-contained HTML file with inlined D3.js for browser-based graph exploration. | |
| 24 | +- **Graph query engine** with direct mode (stats, entities, neighbors, relationships) and agentic mode (natural language queries via LLM). | |
| 25 | +- **Progress callback system** for pipeline status reporting. | |
| 26 | + | |
| 27 | +### Changed | |
| 28 | + | |
| 29 | +- **SQLite replaces FalkorDB** for knowledge graph storage. Zero external dependencies -- no database server or additional packages required. | |
| 30 | +- **Default models** now target cheap/fast options: Claude Haiku, GPT-4o-mini, Gemini Flash. | |
| 31 | +- Output structure updated: `knowledge_graph.db` (SQLite) is now the primary graph file alongside the existing `knowledge_graph.json` export. | |
| 32 | + | |
| 33 | +### Fixed | |
| 34 | + | |
| 35 | +- 821+ tests passing across the full test suite. | |
| 36 | + | |
| 37 | +## [0.3.0] - 2025-12-20 | |
| 38 | + | |
| 39 | +### Added | |
| 40 | + | |
| 41 | +- FalkorDB integration for knowledge graph storage. | |
| 42 | +- Typed relationships and entity properties in graph data model. | |
| 43 | +- Relationship existence checks. | |
| 44 | + | |
| 45 | +## [0.2.0] - 2025-10-15 | |
| 46 | + | |
| 47 | +### Added | |
| 48 | + | |
| 49 | +- Batch video processing with merged knowledge graphs. | |
| 50 | +- Cloud sources: Google Drive and Dropbox shared folder fetching. | |
| 51 | +- Checkpoint/resume for interrupted pipelines. | |
| 52 | +- PDF report generation. | |
| 53 | + | |
| 54 | +## [0.1.0] - 2025-08-01 | |
| 55 | + | |
| 56 | +### Added | |
| 57 | + | |
| 58 | +- Initial release. | |
| 59 | +- Video analysis with multi-provider AI (OpenAI, Anthropic, Gemini, Ollama). | |
| 60 | +- Smart frame extraction with change detection. | |
| 61 | +- People frame filtering via OpenCV face detection. | |
| 62 | +- Diagram extraction and classification. | |
| 63 | +- Knowledge graph extraction (entities and relationships). | |
| 64 | +- Action item detection with assignees and deadlines. | |
| 65 | +- Markdown and HTML report output. | |
| 66 | +- Mermaid diagram generation. | |
| 67 | + | |
| 68 | +[0.4.0]: https://github.com/ConflictHQ/PlanOpticon/compare/v0.3.0...v0.4.0 | |
| 69 | +[0.3.0]: https://github.com/ConflictHQ/PlanOpticon/compare/v0.2.0...v0.3.0 | |
| 70 | +[0.2.0]: https://github.com/ConflictHQ/PlanOpticon/compare/v0.1.0...v0.2.0 | |
| 71 | +[0.1.0]: https://github.com/ConflictHQ/PlanOpticon/releases/tag/v0.1.0 |
| --- a/CHANGELOG.md | |
| +++ b/CHANGELOG.md | |
| @@ -0,0 +1,71 @@ | |
| --- a/CHANGELOG.md | |
| +++ b/CHANGELOG.md | |
| @@ -0,0 +1,71 @@ | |
| 1 | # Changelog |
| 2 | |
| 3 | All notable changes to PlanOpticon are documented in this file. |
| 4 | |
| 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), |
| 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). |
| 7 | |
| 8 | ## [0.4.0] - 2026-03-07 |
| 9 | |
| 10 | ### Added |
| 11 | |
| 12 | - **Planning agent framework** with 11 skills: project_plan, prd, roadmap, task_breakdown, github_integration, requirements_chat, doc_generator, artifact_export, cli_adapter, notes_export, wiki_generator. Invoke via `planopticon agent`. |
| 13 | - **Interactive companion REPL** (`planopticon companion` / `planopticon --chat`) with auto-discovery of knowledge graphs, videos, and documents in the workspace. 15 slash commands for graph exploration, ingestion, export, auth, and runtime provider/model switching. |
| 14 | - **20+ source connectors**: YouTube, Web, GitHub, Reddit, HackerNews, RSS, Podcast, Twitter/X, arXiv, S3, Google Workspace (Docs, Sheets, Slides), Microsoft 365 (SharePoint, OneDrive), Obsidian, Notion, Apple Notes, OneNote, Google Keep, Logseq, Zoom (OAuth), Teams, Google Meet. |
| 15 | - **Pluggable provider registry** supporting 15+ AI providers: OpenAI, Anthropic, Gemini, Ollama, Azure, Together, Fireworks, Cerebras, xAI, Bedrock, Vertex, Mistral, Cohere, AI21, HuggingFace, Qianfan, and LiteLLM. |
| 16 | - **Planning taxonomy classifier** for entity types: goal, requirement, risk, task, milestone, and other planning-specific categories. |
| 17 | - **Unified OAuth manager** (`planopticon auth`) with pre-built configs for Google, Dropbox, Zoom, Notion, GitHub, and Microsoft. Auth chain: saved token, OAuth PKCE, API key fallback. |
| 18 | - **Markdown document generator** producing 7 document types without an LLM: summary, meeting-notes, glossary, relationship-map, status-report, entity-index, csv. |
| 19 | - **Notes export** to Obsidian vaults (YAML frontmatter + wiki-links) and Notion-compatible markdown. |
| 20 | - **GitHub wiki generator** with direct push support. |
| 21 | - **PlanOpticonExchange** canonical JSON interchange format with merge and dedup. |
| 22 | - **Document ingestion pipeline** for PDF, Markdown, and plaintext sources. |
| 23 | - **Knowledge graph viewer** -- self-contained HTML file with inlined D3.js for browser-based graph exploration. |
| 24 | - **Graph query engine** with direct mode (stats, entities, neighbors, relationships) and agentic mode (natural language queries via LLM). |
| 25 | - **Progress callback system** for pipeline status reporting. |
| 26 | |
| 27 | ### Changed |
| 28 | |
| 29 | - **SQLite replaces FalkorDB** for knowledge graph storage. Zero external dependencies -- no database server or additional packages required. |
| 30 | - **Default models** now target cheap/fast options: Claude Haiku, GPT-4o-mini, Gemini Flash. |
| 31 | - Output structure updated: `knowledge_graph.db` (SQLite) is now the primary graph file alongside the existing `knowledge_graph.json` export. |
| 32 | |
| 33 | ### Fixed |
| 34 | |
| 35 | - 821+ tests passing across the full test suite. |
| 36 | |
| 37 | ## [0.3.0] - 2025-12-20 |
| 38 | |
| 39 | ### Added |
| 40 | |
| 41 | - FalkorDB integration for knowledge graph storage. |
| 42 | - Typed relationships and entity properties in graph data model. |
| 43 | - Relationship existence checks. |
| 44 | |
| 45 | ## [0.2.0] - 2025-10-15 |
| 46 | |
| 47 | ### Added |
| 48 | |
| 49 | - Batch video processing with merged knowledge graphs. |
| 50 | - Cloud sources: Google Drive and Dropbox shared folder fetching. |
| 51 | - Checkpoint/resume for interrupted pipelines. |
| 52 | - PDF report generation. |
| 53 | |
| 54 | ## [0.1.0] - 2025-08-01 |
| 55 | |
| 56 | ### Added |
| 57 | |
| 58 | - Initial release. |
| 59 | - Video analysis with multi-provider AI (OpenAI, Anthropic, Gemini, Ollama). |
| 60 | - Smart frame extraction with change detection. |
| 61 | - People frame filtering via OpenCV face detection. |
| 62 | - Diagram extraction and classification. |
| 63 | - Knowledge graph extraction (entities and relationships). |
| 64 | - Action item detection with assignees and deadlines. |
| 65 | - Markdown and HTML report output. |
| 66 | - Mermaid diagram generation. |
| 67 | |
| 68 | [0.4.0]: https://github.com/ConflictHQ/PlanOpticon/compare/v0.3.0...v0.4.0 |
| 69 | [0.3.0]: https://github.com/ConflictHQ/PlanOpticon/compare/v0.2.0...v0.3.0 |
| 70 | [0.2.0]: https://github.com/ConflictHQ/PlanOpticon/compare/v0.1.0...v0.2.0 |
| 71 | [0.1.0]: https://github.com/ConflictHQ/PlanOpticon/releases/tag/v0.1.0 |
+1
-1
| --- CLAUDE.md | ||
| +++ CLAUDE.md | ||
| @@ -7,11 +7,11 @@ | ||
| 7 | 7 | PlanOpticon can build and query knowledge graphs from video content. If you see `knowledge_graph.db` or `knowledge_graph.json` files in the workspace, you can query them to understand what was discussed. |
| 8 | 8 | |
| 9 | 9 | ### Auto-detection |
| 10 | 10 | |
| 11 | 11 | Look for these files (checked automatically): |
| 12 | -- `knowledge_graph.db` — FalkorDB binary graph (preferred) | |
| 12 | +- `knowledge_graph.db` — SQLite graph database (preferred) | |
| 13 | 13 | - `knowledge_graph.json` — JSON export (fallback) |
| 14 | 14 | |
| 15 | 15 | Common locations: project root, `results/`, `output/`, `knowledge-base/`. |
| 16 | 16 | |
| 17 | 17 | ### Quick commands |
| 18 | 18 |
| --- CLAUDE.md | |
| +++ CLAUDE.md | |
| @@ -7,11 +7,11 @@ | |
| 7 | PlanOpticon can build and query knowledge graphs from video content. If you see `knowledge_graph.db` or `knowledge_graph.json` files in the workspace, you can query them to understand what was discussed. |
| 8 | |
| 9 | ### Auto-detection |
| 10 | |
| 11 | Look for these files (checked automatically): |
| 12 | - `knowledge_graph.db` — FalkorDB binary graph (preferred) |
| 13 | - `knowledge_graph.json` — JSON export (fallback) |
| 14 | |
| 15 | Common locations: project root, `results/`, `output/`, `knowledge-base/`. |
| 16 | |
| 17 | ### Quick commands |
| 18 |
| --- CLAUDE.md | |
| +++ CLAUDE.md | |
| @@ -7,11 +7,11 @@ | |
| 7 | PlanOpticon can build and query knowledge graphs from video content. If you see `knowledge_graph.db` or `knowledge_graph.json` files in the workspace, you can query them to understand what was discussed. |
| 8 | |
| 9 | ### Auto-detection |
| 10 | |
| 11 | Look for these files (checked automatically): |
| 12 | - `knowledge_graph.db` — SQLite graph database (preferred) |
| 13 | - `knowledge_graph.json` — JSON export (fallback) |
| 14 | |
| 15 | Common locations: project root, `results/`, `output/`, `knowledge-base/`. |
| 16 | |
| 17 | ### Quick commands |
| 18 |
+105
-33
| --- README.md | ||
| +++ README.md | ||
| @@ -4,31 +4,118 @@ | ||
| 4 | 4 | [](https://pypi.org/project/planopticon/) |
| 5 | 5 | [](https://pypi.org/project/planopticon/) |
| 6 | 6 | [](LICENSE) |
| 7 | 7 | [](https://planopticon.dev) |
| 8 | 8 | |
| 9 | -**AI-powered video analysis and knowledge extraction.** | |
| 9 | +**AI-powered video analysis, knowledge extraction, and planning.** | |
| 10 | 10 | |
| 11 | -PlanOpticon processes video recordings into structured knowledge — transcripts, diagrams, action items, key points, and knowledge graphs. It auto-discovers available models across OpenAI, Anthropic, Gemini, and Ollama, and produces rich multi-format output. | |
| 11 | +PlanOpticon processes video recordings, documents, and 20+ online sources into structured knowledge graphs, then helps you plan with an AI agent and interactive companion. It auto-discovers models across 15+ AI providers, runs fully offline with Ollama, and produces rich multi-format output. | |
| 12 | 12 | |
| 13 | 13 | ## Features |
| 14 | 14 | |
| 15 | -- **Multi-provider AI** — Auto-discovers and routes to the best available model across OpenAI, Anthropic, Google Gemini, and Ollama (fully offline) | |
| 16 | -- **Smart frame extraction** — Change detection for transitions + periodic capture for slow-evolving content (document scrolling, screen shares) | |
| 17 | -- **People frame filtering** — OpenCV face detection automatically removes webcam/video conference frames, keeping only shared content | |
| 18 | -- **Diagram extraction** — Vision model classification detects flowcharts, architecture diagrams, charts, and whiteboards | |
| 19 | -- **Knowledge graphs** — Extracts entities and relationships, builds and merges knowledge graphs across videos | |
| 20 | -- **Action item detection** — Finds commitments, tasks, and follow-ups with assignees and deadlines | |
| 21 | -- **Batch processing** — Process entire folders of videos with merged knowledge graphs and cross-referencing | |
| 22 | -- **Rich output** — Markdown, HTML, PDF reports. Mermaid diagrams, SVG/PNG renderings, JSON manifests | |
| 23 | -- **Cloud sources** — Fetch videos from Google Drive and Dropbox shared folders | |
| 24 | -- **Checkpoint/resume** — Pipeline resumes from where it left off if interrupted | |
| 25 | -- **Screengrab fallback** — When extraction isn't perfect, captures frames with captions — something is always better than nothing | |
| 15 | +- **15+ AI providers** -- OpenAI, Anthropic, Gemini, Ollama, Azure, Together, Fireworks, Cerebras, xAI, Bedrock, Vertex, Mistral, Cohere, AI21, HuggingFace, Qianfan, and LiteLLM. Defaults to cheap models (Haiku, GPT-4o-mini, Gemini Flash). | |
| 16 | +- **20+ source connectors** -- YouTube, web pages, GitHub, Reddit, HackerNews, RSS, podcasts, arXiv, S3, Google Workspace, Microsoft 365, Obsidian, Notion, Apple Notes, Zoom, Teams, Google Meet, and more. | |
| 17 | +- **Planning agent** -- 11 skills including project plans, PRDs, roadmaps, task breakdowns, and GitHub integration. | |
| 18 | +- **Interactive companion** -- Chat REPL with 15 slash commands, auto-discovery of workspace knowledge, and runtime provider/model switching. | |
| 19 | +- **Knowledge graphs** -- SQLite-backed (zero external deps), entity extraction with planning taxonomy (goals, requirements, risks, tasks, milestones), merge and dedup across sources. | |
| 20 | +- **Smart video analysis** -- Change-detection frame extraction, face filtering, diagram classification, action item detection, checkpoint/resume. | |
| 21 | +- **Document ingestion** -- PDF, Markdown, and plaintext pipelines feed the same knowledge graph. | |
| 22 | +- **Export everywhere** -- Markdown docs (7 types, no LLM required), Obsidian vaults, Notion markdown, GitHub wiki with push, PlanOpticonExchange JSON interchange, HTML/PDF reports, Mermaid diagrams. | |
| 23 | +- **OAuth-first auth** -- Unified OAuth manager for Google, Dropbox, Zoom, Notion, GitHub, and Microsoft with saved-token / PKCE / API-key fallback chain. | |
| 24 | +- **Batch processing** -- Process entire folders with merged knowledge graphs and cross-referencing. | |
| 25 | + | |
| 26 | +## Quick Start | |
| 27 | + | |
| 28 | +```bash | |
| 29 | +# Install | |
| 30 | +pip install planopticon | |
| 31 | + | |
| 32 | +# Analyze a video | |
| 33 | +planopticon analyze -i meeting.mp4 -o ./output | |
| 34 | + | |
| 35 | +# Ingest a document | |
| 36 | +planopticon ingest -i spec.pdf -o ./output | |
| 37 | + | |
| 38 | +# Fetch from a source | |
| 39 | +planopticon fetch youtube "https://youtube.com/watch?v=..." -o ./output | |
| 40 | + | |
| 41 | +# Process a folder of videos | |
| 42 | +planopticon batch -i ./recordings -o ./output --title "Weekly Meetings" | |
| 43 | + | |
| 44 | +# Query the knowledge graph | |
| 45 | +planopticon query | |
| 46 | +planopticon query "entities --type technology" | |
| 47 | + | |
| 48 | +# See available AI models | |
| 49 | +planopticon list-models | |
| 50 | +``` | |
| 51 | + | |
| 52 | +## Planning Agent | |
| 53 | + | |
| 54 | +Run AI-powered planning skills against your knowledge base: | |
| 55 | + | |
| 56 | +```bash | |
| 57 | +# Generate a project plan from extracted knowledge | |
| 58 | +planopticon agent "Create a project plan" --kb ./results | |
| 59 | + | |
| 60 | +# Build a PRD | |
| 61 | +planopticon agent "Write a PRD for the authentication system" --kb ./results | |
| 62 | + | |
| 63 | +# Break down tasks | |
| 64 | +planopticon agent "Break this into tasks and estimate effort" --kb ./results | |
| 65 | +``` | |
| 66 | + | |
| 67 | +11 skills: `project_plan`, `prd`, `roadmap`, `task_breakdown`, `github_integration`, `requirements_chat`, `doc_generator`, `artifact_export`, `cli_adapter`, `notes_export`, `wiki_generator`. | |
| 68 | + | |
| 69 | +## Interactive Companion | |
| 70 | + | |
| 71 | +A chat REPL that auto-discovers knowledge graphs, videos, and docs in your workspace: | |
| 72 | + | |
| 73 | +```bash | |
| 74 | +# Launch the companion | |
| 75 | +planopticon companion | |
| 76 | +# or | |
| 77 | +planopticon --chat | |
| 78 | +``` | |
| 79 | + | |
| 80 | +15 slash commands: `/help`, `/status`, `/skills`, `/entities`, `/search`, `/neighbors`, `/export`, `/analyze`, `/ingest`, `/auth`, `/provider`, `/model`, `/run`, `/plan`, `/prd`, `/tasks`. | |
| 81 | + | |
| 82 | +Switch providers and models at runtime, explore your knowledge graph interactively, or chat with any configured LLM. | |
| 83 | + | |
| 84 | +## Source Connectors | |
| 85 | + | |
| 86 | +| Category | Sources | | |
| 87 | +|----------|---------| | |
| 88 | +| Media | YouTube, Web, Podcasts, RSS | | |
| 89 | +| Code & Community | GitHub, Reddit, HackerNews, arXiv | | |
| 90 | +| Cloud Storage | S3, Google Drive, Dropbox | | |
| 91 | +| Google Workspace | Docs, Sheets, Slides (via gws CLI) | | |
| 92 | +| Microsoft 365 | SharePoint, OneDrive (via m365 CLI) | | |
| 93 | +| Notes | Obsidian, Notion, Apple Notes, OneNote, Google Keep, Logseq | | |
| 94 | +| Meetings | Zoom (OAuth), Teams, Google Meet | | |
| 95 | + | |
| 96 | +## Export & Documents | |
| 97 | + | |
| 98 | +Generate documents from your knowledge graph without an LLM: | |
| 99 | + | |
| 100 | +```bash | |
| 101 | +planopticon export summary -o ./docs | |
| 102 | +planopticon export meeting-notes -o ./docs | |
| 103 | +planopticon export glossary -o ./docs | |
| 104 | +``` | |
| 105 | + | |
| 106 | +7 document types: `summary`, `meeting-notes`, `glossary`, `relationship-map`, `status-report`, `entity-index`, `csv`. | |
| 107 | + | |
| 108 | +Additional export targets: | |
| 109 | +- **Obsidian** -- YAML frontmatter + wiki-links vault | |
| 110 | +- **Notion** -- Compatible markdown | |
| 111 | +- **GitHub Wiki** -- Generate and push directly | |
| 112 | +- **PlanOpticonExchange** -- Canonical JSON interchange with merge/dedup | |
| 26 | 113 | |
| 27 | 114 | ## Local Run |
| 28 | 115 | |
| 29 | -PlanOpticon runs entirely offline with Ollama — no API keys, no cloud, no cost. | |
| 116 | +PlanOpticon runs entirely offline with Ollama -- no API keys, no cloud, no cost. | |
| 30 | 117 | |
| 31 | 118 | > **13.2 hours of video content analyzed, knowledge-graphed, and summarized in ~25 hours of processing time, entirely on local hardware, for free.** |
| 32 | 119 | |
| 33 | 120 | 18 meeting recordings processed on a single machine using `llava` (vision), `qwen3:30b` (chat), and `whisper-large` (transcription via Apple Silicon GPU): |
| 34 | 121 | |
| @@ -41,33 +128,17 @@ | ||
| 41 | 128 | | API calls (local) | 1,841 | |
| 42 | 129 | | Tokens processed | 4.87M | |
| 43 | 130 | | Total cost | **$0.00** | |
| 44 | 131 | |
| 45 | 132 | ```bash |
| 46 | -# Fully local analysis — no API keys needed, just Ollama running | |
| 133 | +# Fully local analysis -- no API keys needed, just Ollama running | |
| 47 | 134 | planopticon analyze -i meeting.mp4 -o ./output \ |
| 48 | 135 | --provider ollama \ |
| 49 | 136 | --vision-model llava:latest \ |
| 50 | 137 | --chat-model qwen3:30b |
| 51 | 138 | ``` |
| 52 | 139 | |
| 53 | -## Quick Start | |
| 54 | - | |
| 55 | -```bash | |
| 56 | -# Install | |
| 57 | -pip install planopticon | |
| 58 | - | |
| 59 | -# Analyze a single video | |
| 60 | -planopticon analyze -i meeting.mp4 -o ./output | |
| 61 | - | |
| 62 | -# Process a folder of videos | |
| 63 | -planopticon batch -i ./recordings -o ./output --title "Weekly Meetings" | |
| 64 | - | |
| 65 | -# See available AI models | |
| 66 | -planopticon list-models | |
| 67 | -``` | |
| 68 | - | |
| 69 | 140 | ## Installation |
| 70 | 141 | |
| 71 | 142 | ### From PyPI |
| 72 | 143 | |
| 73 | 144 | ```bash |
| @@ -109,11 +180,12 @@ | ||
| 109 | 180 | ├── captures/ # Screengrab fallbacks |
| 110 | 181 | └── results/ |
| 111 | 182 | ├── analysis.md # Markdown report |
| 112 | 183 | ├── analysis.html # HTML report |
| 113 | 184 | ├── analysis.pdf # PDF report |
| 114 | - ├── knowledge_graph.json # Entities and relationships | |
| 185 | + ├── knowledge_graph.db # SQLite knowledge graph | |
| 186 | + ├── knowledge_graph.json # JSON export | |
| 115 | 187 | ├── key_points.json # Extracted key points |
| 116 | 188 | └── action_items.json # Tasks and follow-ups |
| 117 | 189 | ``` |
| 118 | 190 | |
| 119 | 191 | ## Processing Depth |
| @@ -128,6 +200,6 @@ | ||
| 128 | 200 | |
| 129 | 201 | Full documentation at [planopticon.dev](https://planopticon.dev) |
| 130 | 202 | |
| 131 | 203 | ## License |
| 132 | 204 | |
| 133 | -MIT License — Copyright (c) 2026 CONFLICT LLC | |
| 205 | +MIT License -- Copyright (c) 2026 CONFLICT LLC | |
| 134 | 206 |
| --- README.md | |
| +++ README.md | |
| @@ -4,31 +4,118 @@ | |
| 4 | [](https://pypi.org/project/planopticon/) |
| 5 | [](https://pypi.org/project/planopticon/) |
| 6 | [](LICENSE) |
| 7 | [](https://planopticon.dev) |
| 8 | |
| 9 | **AI-powered video analysis and knowledge extraction.** |
| 10 | |
| 11 | PlanOpticon processes video recordings into structured knowledge — transcripts, diagrams, action items, key points, and knowledge graphs. It auto-discovers available models across OpenAI, Anthropic, Gemini, and Ollama, and produces rich multi-format output. |
| 12 | |
| 13 | ## Features |
| 14 | |
| 15 | - **Multi-provider AI** — Auto-discovers and routes to the best available model across OpenAI, Anthropic, Google Gemini, and Ollama (fully offline) |
| 16 | - **Smart frame extraction** — Change detection for transitions + periodic capture for slow-evolving content (document scrolling, screen shares) |
| 17 | - **People frame filtering** — OpenCV face detection automatically removes webcam/video conference frames, keeping only shared content |
| 18 | - **Diagram extraction** — Vision model classification detects flowcharts, architecture diagrams, charts, and whiteboards |
| 19 | - **Knowledge graphs** — Extracts entities and relationships, builds and merges knowledge graphs across videos |
| 20 | - **Action item detection** — Finds commitments, tasks, and follow-ups with assignees and deadlines |
| 21 | - **Batch processing** — Process entire folders of videos with merged knowledge graphs and cross-referencing |
| 22 | - **Rich output** — Markdown, HTML, PDF reports. Mermaid diagrams, SVG/PNG renderings, JSON manifests |
| 23 | - **Cloud sources** — Fetch videos from Google Drive and Dropbox shared folders |
| 24 | - **Checkpoint/resume** — Pipeline resumes from where it left off if interrupted |
| 25 | - **Screengrab fallback** — When extraction isn't perfect, captures frames with captions — something is always better than nothing |
| 26 | |
| 27 | ## Local Run |
| 28 | |
| 29 | PlanOpticon runs entirely offline with Ollama — no API keys, no cloud, no cost. |
| 30 | |
| 31 | > **13.2 hours of video content analyzed, knowledge-graphed, and summarized in ~25 hours of processing time, entirely on local hardware, for free.** |
| 32 | |
| 33 | 18 meeting recordings processed on a single machine using `llava` (vision), `qwen3:30b` (chat), and `whisper-large` (transcription via Apple Silicon GPU): |
| 34 | |
| @@ -41,33 +128,17 @@ | |
| 41 | | API calls (local) | 1,841 | |
| 42 | | Tokens processed | 4.87M | |
| 43 | | Total cost | **$0.00** | |
| 44 | |
| 45 | ```bash |
| 46 | # Fully local analysis — no API keys needed, just Ollama running |
| 47 | planopticon analyze -i meeting.mp4 -o ./output \ |
| 48 | --provider ollama \ |
| 49 | --vision-model llava:latest \ |
| 50 | --chat-model qwen3:30b |
| 51 | ``` |
| 52 | |
| 53 | ## Quick Start |
| 54 | |
| 55 | ```bash |
| 56 | # Install |
| 57 | pip install planopticon |
| 58 | |
| 59 | # Analyze a single video |
| 60 | planopticon analyze -i meeting.mp4 -o ./output |
| 61 | |
| 62 | # Process a folder of videos |
| 63 | planopticon batch -i ./recordings -o ./output --title "Weekly Meetings" |
| 64 | |
| 65 | # See available AI models |
| 66 | planopticon list-models |
| 67 | ``` |
| 68 | |
| 69 | ## Installation |
| 70 | |
| 71 | ### From PyPI |
| 72 | |
| 73 | ```bash |
| @@ -109,11 +180,12 @@ | |
| 109 | ├── captures/ # Screengrab fallbacks |
| 110 | └── results/ |
| 111 | ├── analysis.md # Markdown report |
| 112 | ├── analysis.html # HTML report |
| 113 | ├── analysis.pdf # PDF report |
| 114 | ├── knowledge_graph.json # Entities and relationships |
| 115 | ├── key_points.json # Extracted key points |
| 116 | └── action_items.json # Tasks and follow-ups |
| 117 | ``` |
| 118 | |
| 119 | ## Processing Depth |
| @@ -128,6 +200,6 @@ | |
| 128 | |
| 129 | Full documentation at [planopticon.dev](https://planopticon.dev) |
| 130 | |
| 131 | ## License |
| 132 | |
| 133 | MIT License — Copyright (c) 2026 CONFLICT LLC |
| 134 |
| --- README.md | |
| +++ README.md | |
| @@ -4,31 +4,118 @@ | |
| 4 | [](https://pypi.org/project/planopticon/) |
| 5 | [](https://pypi.org/project/planopticon/) |
| 6 | [](LICENSE) |
| 7 | [](https://planopticon.dev) |
| 8 | |
| 9 | **AI-powered video analysis, knowledge extraction, and planning.** |
| 10 | |
| 11 | PlanOpticon processes video recordings, documents, and 20+ online sources into structured knowledge graphs, then helps you plan with an AI agent and interactive companion. It auto-discovers models across 15+ AI providers, runs fully offline with Ollama, and produces rich multi-format output. |
| 12 | |
| 13 | ## Features |
| 14 | |
| 15 | - **15+ AI providers** -- OpenAI, Anthropic, Gemini, Ollama, Azure, Together, Fireworks, Cerebras, xAI, Bedrock, Vertex, Mistral, Cohere, AI21, HuggingFace, Qianfan, and LiteLLM. Defaults to cheap models (Haiku, GPT-4o-mini, Gemini Flash). |
| 16 | - **20+ source connectors** -- YouTube, web pages, GitHub, Reddit, HackerNews, RSS, podcasts, arXiv, S3, Google Workspace, Microsoft 365, Obsidian, Notion, Apple Notes, Zoom, Teams, Google Meet, and more. |
| 17 | - **Planning agent** -- 11 skills including project plans, PRDs, roadmaps, task breakdowns, and GitHub integration. |
| 18 | - **Interactive companion** -- Chat REPL with 15 slash commands, auto-discovery of workspace knowledge, and runtime provider/model switching. |
| 19 | - **Knowledge graphs** -- SQLite-backed (zero external deps), entity extraction with planning taxonomy (goals, requirements, risks, tasks, milestones), merge and dedup across sources. |
| 20 | - **Smart video analysis** -- Change-detection frame extraction, face filtering, diagram classification, action item detection, checkpoint/resume. |
| 21 | - **Document ingestion** -- PDF, Markdown, and plaintext pipelines feed the same knowledge graph. |
| 22 | - **Export everywhere** -- Markdown docs (7 types, no LLM required), Obsidian vaults, Notion markdown, GitHub wiki with push, PlanOpticonExchange JSON interchange, HTML/PDF reports, Mermaid diagrams. |
| 23 | - **OAuth-first auth** -- Unified OAuth manager for Google, Dropbox, Zoom, Notion, GitHub, and Microsoft with saved-token / PKCE / API-key fallback chain. |
| 24 | - **Batch processing** -- Process entire folders with merged knowledge graphs and cross-referencing. |
| 25 | |
| 26 | ## Quick Start |
| 27 | |
| 28 | ```bash |
| 29 | # Install |
| 30 | pip install planopticon |
| 31 | |
| 32 | # Analyze a video |
| 33 | planopticon analyze -i meeting.mp4 -o ./output |
| 34 | |
| 35 | # Ingest a document |
| 36 | planopticon ingest -i spec.pdf -o ./output |
| 37 | |
| 38 | # Fetch from a source |
| 39 | planopticon fetch youtube "https://youtube.com/watch?v=..." -o ./output |
| 40 | |
| 41 | # Process a folder of videos |
| 42 | planopticon batch -i ./recordings -o ./output --title "Weekly Meetings" |
| 43 | |
| 44 | # Query the knowledge graph |
| 45 | planopticon query |
| 46 | planopticon query "entities --type technology" |
| 47 | |
| 48 | # See available AI models |
| 49 | planopticon list-models |
| 50 | ``` |
| 51 | |
| 52 | ## Planning Agent |
| 53 | |
| 54 | Run AI-powered planning skills against your knowledge base: |
| 55 | |
| 56 | ```bash |
| 57 | # Generate a project plan from extracted knowledge |
| 58 | planopticon agent "Create a project plan" --kb ./results |
| 59 | |
| 60 | # Build a PRD |
| 61 | planopticon agent "Write a PRD for the authentication system" --kb ./results |
| 62 | |
| 63 | # Break down tasks |
| 64 | planopticon agent "Break this into tasks and estimate effort" --kb ./results |
| 65 | ``` |
| 66 | |
| 67 | 11 skills: `project_plan`, `prd`, `roadmap`, `task_breakdown`, `github_integration`, `requirements_chat`, `doc_generator`, `artifact_export`, `cli_adapter`, `notes_export`, `wiki_generator`. |
| 68 | |
| 69 | ## Interactive Companion |
| 70 | |
| 71 | A chat REPL that auto-discovers knowledge graphs, videos, and docs in your workspace: |
| 72 | |
| 73 | ```bash |
| 74 | # Launch the companion |
| 75 | planopticon companion |
| 76 | # or |
| 77 | planopticon --chat |
| 78 | ``` |
| 79 | |
| 80 | 15 slash commands: `/help`, `/status`, `/skills`, `/entities`, `/search`, `/neighbors`, `/export`, `/analyze`, `/ingest`, `/auth`, `/provider`, `/model`, `/run`, `/plan`, `/prd`, `/tasks`. |
| 81 | |
| 82 | Switch providers and models at runtime, explore your knowledge graph interactively, or chat with any configured LLM. |
| 83 | |
| 84 | ## Source Connectors |
| 85 | |
| 86 | | Category | Sources | |
| 87 | |----------|---------| |
| 88 | | Media | YouTube, Web, Podcasts, RSS | |
| 89 | | Code & Community | GitHub, Reddit, HackerNews, arXiv | |
| 90 | | Cloud Storage | S3, Google Drive, Dropbox | |
| 91 | | Google Workspace | Docs, Sheets, Slides (via gws CLI) | |
| 92 | | Microsoft 365 | SharePoint, OneDrive (via m365 CLI) | |
| 93 | | Notes | Obsidian, Notion, Apple Notes, OneNote, Google Keep, Logseq | |
| 94 | | Meetings | Zoom (OAuth), Teams, Google Meet | |
| 95 | |
| 96 | ## Export & Documents |
| 97 | |
| 98 | Generate documents from your knowledge graph without an LLM: |
| 99 | |
| 100 | ```bash |
| 101 | planopticon export summary -o ./docs |
| 102 | planopticon export meeting-notes -o ./docs |
| 103 | planopticon export glossary -o ./docs |
| 104 | ``` |
| 105 | |
| 106 | 7 document types: `summary`, `meeting-notes`, `glossary`, `relationship-map`, `status-report`, `entity-index`, `csv`. |
| 107 | |
| 108 | Additional export targets: |
| 109 | - **Obsidian** -- YAML frontmatter + wiki-links vault |
| 110 | - **Notion** -- Compatible markdown |
| 111 | - **GitHub Wiki** -- Generate and push directly |
| 112 | - **PlanOpticonExchange** -- Canonical JSON interchange with merge/dedup |
| 113 | |
| 114 | ## Local Run |
| 115 | |
| 116 | PlanOpticon runs entirely offline with Ollama -- no API keys, no cloud, no cost. |
| 117 | |
| 118 | > **13.2 hours of video content analyzed, knowledge-graphed, and summarized in ~25 hours of processing time, entirely on local hardware, for free.** |
| 119 | |
| 120 | 18 meeting recordings processed on a single machine using `llava` (vision), `qwen3:30b` (chat), and `whisper-large` (transcription via Apple Silicon GPU): |
| 121 | |
| @@ -41,33 +128,17 @@ | |
| 128 | | API calls (local) | 1,841 | |
| 129 | | Tokens processed | 4.87M | |
| 130 | | Total cost | **$0.00** | |
| 131 | |
| 132 | ```bash |
| 133 | # Fully local analysis -- no API keys needed, just Ollama running |
| 134 | planopticon analyze -i meeting.mp4 -o ./output \ |
| 135 | --provider ollama \ |
| 136 | --vision-model llava:latest \ |
| 137 | --chat-model qwen3:30b |
| 138 | ``` |
| 139 | |
| 140 | ## Installation |
| 141 | |
| 142 | ### From PyPI |
| 143 | |
| 144 | ```bash |
| @@ -109,11 +180,12 @@ | |
| 180 | ├── captures/ # Screengrab fallbacks |
| 181 | └── results/ |
| 182 | ├── analysis.md # Markdown report |
| 183 | ├── analysis.html # HTML report |
| 184 | ├── analysis.pdf # PDF report |
| 185 | ├── knowledge_graph.db # SQLite knowledge graph |
| 186 | ├── knowledge_graph.json # JSON export |
| 187 | ├── key_points.json # Extracted key points |
| 188 | └── action_items.json # Tasks and follow-ups |
| 189 | ``` |
| 190 | |
| 191 | ## Processing Depth |
| @@ -128,6 +200,6 @@ | |
| 200 | |
| 201 | Full documentation at [planopticon.dev](https://planopticon.dev) |
| 202 | |
| 203 | ## License |
| 204 | |
| 205 | MIT License -- Copyright (c) 2026 CONFLICT LLC |
| 206 |
+83
-6
| --- docs/architecture/overview.md | ||
| +++ docs/architecture/overview.md | ||
| @@ -2,67 +2,144 @@ | ||
| 2 | 2 | |
| 3 | 3 | ## System diagram |
| 4 | 4 | |
| 5 | 5 | ```mermaid |
| 6 | 6 | graph TD |
| 7 | - A[Video Input] --> B[Frame Extractor] | |
| 7 | + subgraph Sources | |
| 8 | + S1[Video Files] | |
| 9 | + S2[Google Workspace] | |
| 10 | + S3[Microsoft 365] | |
| 11 | + S4[Zoom / Teams / Meet] | |
| 12 | + S5[YouTube] | |
| 13 | + S6[Notes — Obsidian / Notion / Apple Notes] | |
| 14 | + S7[GitHub] | |
| 15 | + end | |
| 16 | + | |
| 17 | + subgraph Source Connectors | |
| 18 | + SC[Source Connectors + OAuth] | |
| 19 | + end | |
| 20 | + | |
| 21 | + S1 --> SC | |
| 22 | + S2 --> SC | |
| 23 | + S3 --> SC | |
| 24 | + S4 --> SC | |
| 25 | + S5 --> SC | |
| 26 | + S6 --> SC | |
| 27 | + S7 --> SC | |
| 28 | + | |
| 29 | + SC --> A[Ingest / Analyze Pipeline] | |
| 30 | + | |
| 31 | + A --> B[Frame Extractor] | |
| 8 | 32 | A --> C[Audio Extractor] |
| 9 | 33 | B --> D[Diagram Analyzer] |
| 10 | 34 | C --> E[Transcription] |
| 11 | 35 | D --> F[Knowledge Graph] |
| 12 | 36 | E --> F |
| 13 | 37 | E --> G[Key Point Extractor] |
| 14 | 38 | E --> H[Action Item Detector] |
| 15 | 39 | D --> I[Content Analyzer] |
| 16 | 40 | E --> I |
| 41 | + | |
| 42 | + subgraph Agent & Skills | |
| 43 | + AG[Planning Agent] | |
| 44 | + SK[Skill Registry] | |
| 45 | + CO[Companion REPL] | |
| 46 | + end | |
| 47 | + | |
| 48 | + F --> AG | |
| 49 | + G --> AG | |
| 50 | + H --> AG | |
| 51 | + I --> AG | |
| 52 | + AG --> SK | |
| 53 | + F --> CO | |
| 54 | + | |
| 17 | 55 | F --> J[Plan Generator] |
| 18 | 56 | G --> J |
| 19 | 57 | H --> J |
| 20 | 58 | I --> J |
| 21 | - J --> K[Markdown Report] | |
| 22 | - J --> L[HTML Report] | |
| 23 | - J --> M[PDF Report] | |
| 24 | - D --> N[Mermaid/SVG/PNG Export] | |
| 59 | + | |
| 60 | + subgraph Output & Export | |
| 61 | + J --> K[Markdown Report] | |
| 62 | + J --> L[HTML Report] | |
| 63 | + J --> M[PDF Report] | |
| 64 | + D --> N[Mermaid/SVG/PNG Export] | |
| 65 | + EX[Exporters — Obsidian / Notion / Exchange / Wiki] | |
| 66 | + end | |
| 67 | + | |
| 68 | + AG --> EX | |
| 69 | + F --> EX | |
| 25 | 70 | ``` |
| 26 | 71 | |
| 27 | 72 | ## Module structure |
| 28 | 73 | |
| 29 | 74 | ``` |
| 30 | 75 | video_processor/ |
| 31 | 76 | ├── cli/ # CLI commands (Click) |
| 32 | 77 | │ └── commands.py |
| 78 | +├── sources/ # Source connectors | |
| 79 | +│ ├── gdrive.py # Google Drive | |
| 80 | +│ ├── gws.py # Google Workspace (Docs, Sheets, Slides, Meet) | |
| 81 | +│ ├── m365.py # Microsoft 365 (OneDrive, SharePoint, Teams) | |
| 82 | +│ ├── dropbox.py # Dropbox | |
| 83 | +│ ├── zoom.py # Zoom recordings | |
| 84 | +│ ├── youtube.py # YouTube videos | |
| 85 | +│ ├── notion.py # Notion pages | |
| 86 | +│ ├── github.py # GitHub repos / wikis | |
| 87 | +│ ├── obsidian.py # Obsidian vaults | |
| 88 | +│ └── apple_notes.py # Apple Notes (macOS) | |
| 33 | 89 | ├── extractors/ # Media extraction |
| 34 | 90 | │ ├── frame_extractor.py # Video → frames |
| 35 | 91 | │ └── audio_extractor.py # Video → WAV |
| 36 | 92 | ├── analyzers/ # AI-powered analysis |
| 37 | 93 | │ ├── diagram_analyzer.py # Frame classification + extraction |
| 38 | 94 | │ ├── content_analyzer.py # Cross-referencing |
| 39 | 95 | │ └── action_detector.py # Action item detection |
| 40 | 96 | ├── integrators/ # Knowledge assembly |
| 41 | 97 | │ ├── knowledge_graph.py # Entity/relationship graph |
| 98 | +│ ├── graph_query.py # Query engine | |
| 42 | 99 | │ └── plan_generator.py # Report generation |
| 100 | +├── agent/ # Planning agent | |
| 101 | +│ ├── agent.py # Agent loop | |
| 102 | +│ ├── skills.py # Skill registry | |
| 103 | +│ └── companion.py # Companion REPL | |
| 104 | +├── exporters/ # Export formats | |
| 105 | +│ ├── markdown.py # Markdown export | |
| 106 | +│ ├── obsidian.py # Obsidian vault export | |
| 107 | +│ ├── notion.py # Notion export | |
| 108 | +│ ├── wiki.py # Wiki generation + push | |
| 109 | +│ └── exchange.py # PlanOpticon Exchange Format | |
| 43 | 110 | ├── providers/ # AI provider abstraction |
| 44 | 111 | │ ├── base.py # BaseProvider ABC |
| 45 | 112 | │ ├── openai_provider.py |
| 46 | 113 | │ ├── anthropic_provider.py |
| 47 | 114 | │ ├── gemini_provider.py |
| 48 | 115 | │ ├── ollama_provider.py # Local Ollama (offline) |
| 116 | +│ ├── azure_provider.py # Azure OpenAI | |
| 117 | +│ ├── together_provider.py | |
| 118 | +│ ├── fireworks_provider.py | |
| 119 | +│ ├── cerebras_provider.py | |
| 120 | +│ ├── xai_provider.py # xAI / Grok | |
| 49 | 121 | │ ├── discovery.py # Auto-model-discovery |
| 50 | 122 | │ └── manager.py # ProviderManager routing |
| 51 | 123 | ├── utils/ |
| 52 | 124 | │ ├── json_parsing.py # Robust LLM JSON parsing |
| 53 | 125 | │ ├── rendering.py # Mermaid + chart rendering |
| 54 | 126 | │ ├── export.py # HTML/PDF export |
| 55 | 127 | │ ├── api_cache.py # Disk-based response cache |
| 56 | 128 | │ └── prompt_templates.py # LLM prompt management |
| 129 | +├── auth.py # OAuth flow management | |
| 130 | +├── exchange.py # Exchange format schema | |
| 57 | 131 | ├── models.py # Pydantic data models |
| 58 | 132 | ├── output_structure.py # Directory layout + manifest I/O |
| 59 | 133 | └── pipeline.py # Core processing pipeline |
| 60 | 134 | ``` |
| 61 | 135 | |
| 62 | 136 | ## Key design decisions |
| 63 | 137 | |
| 64 | 138 | - **Pydantic everywhere** — All structured data uses pydantic models for validation and serialization |
| 65 | 139 | - **Manifest-driven** — Every run produces `manifest.json` as the single source of truth |
| 66 | -- **Provider abstraction** — Single `ProviderManager` wraps OpenAI, Anthropic, Gemini, and Ollama behind a common interface | |
| 140 | +- **Provider abstraction** — Single `ProviderManager` wraps OpenAI, Anthropic, Gemini, Ollama, and additional providers behind a common interface | |
| 67 | 141 | - **No hardcoded models** — Model lists come from API discovery |
| 68 | 142 | - **Screengrab fallback** — When extraction fails, save the frame as a captioned screenshot |
| 143 | +- **OAuth-first auth** — All cloud service integrations use OAuth via `planopticon auth`, with credentials stored locally. Service account keys are supported as a fallback for server-side automation | |
| 144 | +- **Skill registry** — The planning agent discovers and invokes skills dynamically. Skills are self-describing and can be composed by the agent to accomplish complex tasks | |
| 145 | +- **Exchange format** — A portable JSON format (`exchange.py`) for importing and exporting knowledge graphs between PlanOpticon instances and external tools | |
| 69 | 146 |
| --- docs/architecture/overview.md | |
| +++ docs/architecture/overview.md | |
| @@ -2,67 +2,144 @@ | |
| 2 | |
| 3 | ## System diagram |
| 4 | |
| 5 | ```mermaid |
| 6 | graph TD |
| 7 | A[Video Input] --> B[Frame Extractor] |
| 8 | A --> C[Audio Extractor] |
| 9 | B --> D[Diagram Analyzer] |
| 10 | C --> E[Transcription] |
| 11 | D --> F[Knowledge Graph] |
| 12 | E --> F |
| 13 | E --> G[Key Point Extractor] |
| 14 | E --> H[Action Item Detector] |
| 15 | D --> I[Content Analyzer] |
| 16 | E --> I |
| 17 | F --> J[Plan Generator] |
| 18 | G --> J |
| 19 | H --> J |
| 20 | I --> J |
| 21 | J --> K[Markdown Report] |
| 22 | J --> L[HTML Report] |
| 23 | J --> M[PDF Report] |
| 24 | D --> N[Mermaid/SVG/PNG Export] |
| 25 | ``` |
| 26 | |
| 27 | ## Module structure |
| 28 | |
| 29 | ``` |
| 30 | video_processor/ |
| 31 | ├── cli/ # CLI commands (Click) |
| 32 | │ └── commands.py |
| 33 | ├── extractors/ # Media extraction |
| 34 | │ ├── frame_extractor.py # Video → frames |
| 35 | │ └── audio_extractor.py # Video → WAV |
| 36 | ├── analyzers/ # AI-powered analysis |
| 37 | │ ├── diagram_analyzer.py # Frame classification + extraction |
| 38 | │ ├── content_analyzer.py # Cross-referencing |
| 39 | │ └── action_detector.py # Action item detection |
| 40 | ├── integrators/ # Knowledge assembly |
| 41 | │ ├── knowledge_graph.py # Entity/relationship graph |
| 42 | │ └── plan_generator.py # Report generation |
| 43 | ├── providers/ # AI provider abstraction |
| 44 | │ ├── base.py # BaseProvider ABC |
| 45 | │ ├── openai_provider.py |
| 46 | │ ├── anthropic_provider.py |
| 47 | │ ├── gemini_provider.py |
| 48 | │ ├── ollama_provider.py # Local Ollama (offline) |
| 49 | │ ├── discovery.py # Auto-model-discovery |
| 50 | │ └── manager.py # ProviderManager routing |
| 51 | ├── utils/ |
| 52 | │ ├── json_parsing.py # Robust LLM JSON parsing |
| 53 | │ ├── rendering.py # Mermaid + chart rendering |
| 54 | │ ├── export.py # HTML/PDF export |
| 55 | │ ├── api_cache.py # Disk-based response cache |
| 56 | │ └── prompt_templates.py # LLM prompt management |
| 57 | ├── models.py # Pydantic data models |
| 58 | ├── output_structure.py # Directory layout + manifest I/O |
| 59 | └── pipeline.py # Core processing pipeline |
| 60 | ``` |
| 61 | |
| 62 | ## Key design decisions |
| 63 | |
| 64 | - **Pydantic everywhere** — All structured data uses pydantic models for validation and serialization |
| 65 | - **Manifest-driven** — Every run produces `manifest.json` as the single source of truth |
| 66 | - **Provider abstraction** — Single `ProviderManager` wraps OpenAI, Anthropic, Gemini, and Ollama behind a common interface |
| 67 | - **No hardcoded models** — Model lists come from API discovery |
| 68 | - **Screengrab fallback** — When extraction fails, save the frame as a captioned screenshot |
| 69 |
| --- docs/architecture/overview.md | |
| +++ docs/architecture/overview.md | |
| @@ -2,67 +2,144 @@ | |
| 2 | |
| 3 | ## System diagram |
| 4 | |
| 5 | ```mermaid |
| 6 | graph TD |
| 7 | subgraph Sources |
| 8 | S1[Video Files] |
| 9 | S2[Google Workspace] |
| 10 | S3[Microsoft 365] |
| 11 | S4[Zoom / Teams / Meet] |
| 12 | S5[YouTube] |
| 13 | S6[Notes — Obsidian / Notion / Apple Notes] |
| 14 | S7[GitHub] |
| 15 | end |
| 16 | |
| 17 | subgraph Source Connectors |
| 18 | SC[Source Connectors + OAuth] |
| 19 | end |
| 20 | |
| 21 | S1 --> SC |
| 22 | S2 --> SC |
| 23 | S3 --> SC |
| 24 | S4 --> SC |
| 25 | S5 --> SC |
| 26 | S6 --> SC |
| 27 | S7 --> SC |
| 28 | |
| 29 | SC --> A[Ingest / Analyze Pipeline] |
| 30 | |
| 31 | A --> B[Frame Extractor] |
| 32 | A --> C[Audio Extractor] |
| 33 | B --> D[Diagram Analyzer] |
| 34 | C --> E[Transcription] |
| 35 | D --> F[Knowledge Graph] |
| 36 | E --> F |
| 37 | E --> G[Key Point Extractor] |
| 38 | E --> H[Action Item Detector] |
| 39 | D --> I[Content Analyzer] |
| 40 | E --> I |
| 41 | |
| 42 | subgraph Agent & Skills |
| 43 | AG[Planning Agent] |
| 44 | SK[Skill Registry] |
| 45 | CO[Companion REPL] |
| 46 | end |
| 47 | |
| 48 | F --> AG |
| 49 | G --> AG |
| 50 | H --> AG |
| 51 | I --> AG |
| 52 | AG --> SK |
| 53 | F --> CO |
| 54 | |
| 55 | F --> J[Plan Generator] |
| 56 | G --> J |
| 57 | H --> J |
| 58 | I --> J |
| 59 | |
| 60 | subgraph Output & Export |
| 61 | J --> K[Markdown Report] |
| 62 | J --> L[HTML Report] |
| 63 | J --> M[PDF Report] |
| 64 | D --> N[Mermaid/SVG/PNG Export] |
| 65 | EX[Exporters — Obsidian / Notion / Exchange / Wiki] |
| 66 | end |
| 67 | |
| 68 | AG --> EX |
| 69 | F --> EX |
| 70 | ``` |
| 71 | |
| 72 | ## Module structure |
| 73 | |
| 74 | ``` |
| 75 | video_processor/ |
| 76 | ├── cli/ # CLI commands (Click) |
| 77 | │ └── commands.py |
| 78 | ├── sources/ # Source connectors |
| 79 | │ ├── gdrive.py # Google Drive |
| 80 | │ ├── gws.py # Google Workspace (Docs, Sheets, Slides, Meet) |
| 81 | │ ├── m365.py # Microsoft 365 (OneDrive, SharePoint, Teams) |
| 82 | │ ├── dropbox.py # Dropbox |
| 83 | │ ├── zoom.py # Zoom recordings |
| 84 | │ ├── youtube.py # YouTube videos |
| 85 | │ ├── notion.py # Notion pages |
| 86 | │ ├── github.py # GitHub repos / wikis |
| 87 | │ ├── obsidian.py # Obsidian vaults |
| 88 | │ └── apple_notes.py # Apple Notes (macOS) |
| 89 | ├── extractors/ # Media extraction |
| 90 | │ ├── frame_extractor.py # Video → frames |
| 91 | │ └── audio_extractor.py # Video → WAV |
| 92 | ├── analyzers/ # AI-powered analysis |
| 93 | │ ├── diagram_analyzer.py # Frame classification + extraction |
| 94 | │ ├── content_analyzer.py # Cross-referencing |
| 95 | │ └── action_detector.py # Action item detection |
| 96 | ├── integrators/ # Knowledge assembly |
| 97 | │ ├── knowledge_graph.py # Entity/relationship graph |
| 98 | │ ├── graph_query.py # Query engine |
| 99 | │ └── plan_generator.py # Report generation |
| 100 | ├── agent/ # Planning agent |
| 101 | │ ├── agent.py # Agent loop |
| 102 | │ ├── skills.py # Skill registry |
| 103 | │ └── companion.py # Companion REPL |
| 104 | ├── exporters/ # Export formats |
| 105 | │ ├── markdown.py # Markdown export |
| 106 | │ ├── obsidian.py # Obsidian vault export |
| 107 | │ ├── notion.py # Notion export |
| 108 | │ ├── wiki.py # Wiki generation + push |
| 109 | │ └── exchange.py # PlanOpticon Exchange Format |
| 110 | ├── providers/ # AI provider abstraction |
| 111 | │ ├── base.py # BaseProvider ABC |
| 112 | │ ├── openai_provider.py |
| 113 | │ ├── anthropic_provider.py |
| 114 | │ ├── gemini_provider.py |
| 115 | │ ├── ollama_provider.py # Local Ollama (offline) |
| 116 | │ ├── azure_provider.py # Azure OpenAI |
| 117 | │ ├── together_provider.py |
| 118 | │ ├── fireworks_provider.py |
| 119 | │ ├── cerebras_provider.py |
| 120 | │ ├── xai_provider.py # xAI / Grok |
| 121 | │ ├── discovery.py # Auto-model-discovery |
| 122 | │ └── manager.py # ProviderManager routing |
| 123 | ├── utils/ |
| 124 | │ ├── json_parsing.py # Robust LLM JSON parsing |
| 125 | │ ├── rendering.py # Mermaid + chart rendering |
| 126 | │ ├── export.py # HTML/PDF export |
| 127 | │ ├── api_cache.py # Disk-based response cache |
| 128 | │ └── prompt_templates.py # LLM prompt management |
| 129 | ├── auth.py # OAuth flow management |
| 130 | ├── exchange.py # Exchange format schema |
| 131 | ├── models.py # Pydantic data models |
| 132 | ├── output_structure.py # Directory layout + manifest I/O |
| 133 | └── pipeline.py # Core processing pipeline |
| 134 | ``` |
| 135 | |
| 136 | ## Key design decisions |
| 137 | |
| 138 | - **Pydantic everywhere** — All structured data uses pydantic models for validation and serialization |
| 139 | - **Manifest-driven** — Every run produces `manifest.json` as the single source of truth |
| 140 | - **Provider abstraction** — Single `ProviderManager` wraps OpenAI, Anthropic, Gemini, Ollama, and additional providers behind a common interface |
| 141 | - **No hardcoded models** — Model lists come from API discovery |
| 142 | - **Screengrab fallback** — When extraction fails, save the frame as a captioned screenshot |
| 143 | - **OAuth-first auth** — All cloud service integrations use OAuth via `planopticon auth`, with credentials stored locally. Service account keys are supported as a fallback for server-side automation |
| 144 | - **Skill registry** — The planning agent discovers and invokes skills dynamically. Skills are self-describing and can be composed by the agent to accomplish complex tasks |
| 145 | - **Exchange format** — A portable JSON format (`exchange.py`) for importing and exporting knowledge graphs between PlanOpticon instances and external tools |
| 146 |
+41
-12
| --- docs/architecture/providers.md | ||
| +++ docs/architecture/providers.md | ||
| @@ -1,19 +1,36 @@ | ||
| 1 | 1 | # Provider System |
| 2 | 2 | |
| 3 | 3 | ## Overview |
| 4 | 4 | |
| 5 | -PlanOpticon supports multiple AI providers through a unified abstraction layer. | |
| 5 | +PlanOpticon supports multiple AI providers through a unified abstraction layer. Default models favor cost-effective options (Haiku, GPT-4o-mini, Gemini Flash) for routine tasks, with more capable models available when needed. | |
| 6 | 6 | |
| 7 | 7 | ## Supported providers |
| 8 | 8 | |
| 9 | -| Provider | Chat | Vision | Transcription | | |
| 10 | -|----------|------|--------|--------------| | |
| 11 | -| OpenAI | GPT-4o, GPT-4 | GPT-4o | Whisper-1 | | |
| 12 | -| Anthropic | Claude Sonnet/Opus | Claude Sonnet/Opus | — | | |
| 13 | -| Google Gemini | Gemini Flash/Pro | Gemini Flash/Pro | Gemini Flash | | |
| 14 | -| Ollama (local) | Any installed model | llava, moondream, etc. | — (use local Whisper) | | |
| 9 | +| Provider | Chat | Vision | Transcription | Env Variable | | |
| 10 | +|----------|------|--------|--------------|--------------| | |
| 11 | +| OpenAI | GPT-4o-mini, GPT-4o | GPT-4o-mini, GPT-4o | Whisper-1 | `OPENAI_API_KEY` | | |
| 12 | +| Anthropic | Claude Haiku, Sonnet, Opus | Claude Haiku, Sonnet, Opus | — | `ANTHROPIC_API_KEY` | | |
| 13 | +| Google Gemini | Gemini Flash, Pro | Gemini Flash, Pro | Gemini Flash | `GEMINI_API_KEY` | | |
| 14 | +| Azure OpenAI | GPT-4o-mini, GPT-4o | GPT-4o-mini, GPT-4o | Whisper-1 | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT` | | |
| 15 | +| Together AI | Llama, Mixtral, etc. | Llava | — | `TOGETHER_API_KEY` | | |
| 16 | +| Fireworks AI | Llama, Mixtral, etc. | Llava | — | `FIREWORKS_API_KEY` | | |
| 17 | +| Cerebras | Llama (fast inference) | — | — | `CEREBRAS_API_KEY` | | |
| 18 | +| xAI | Grok | Grok | — | `XAI_API_KEY` | | |
| 19 | +| Ollama (local) | Any installed model | llava, moondream, etc. | — (use local Whisper) | `OLLAMA_HOST` | | |
| 20 | + | |
| 21 | +## Default models | |
| 22 | + | |
| 23 | +PlanOpticon defaults to cheap, fast models for cost efficiency: | |
| 24 | + | |
| 25 | +| Task | Default model | | |
| 26 | +|------|--------------| | |
| 27 | +| Vision (diagrams) | Gemini Flash | | |
| 28 | +| Chat (analysis) | Claude Haiku | | |
| 29 | +| Transcription | Local Whisper (fallback: Whisper-1) | | |
| 30 | + | |
| 31 | +Use `--vision-model` and `--chat-model` to override with more capable models when needed (e.g., `--chat-model claude-sonnet-4-20250514` for complex analysis). | |
| 15 | 32 | |
| 16 | 33 | ## Ollama (offline mode) |
| 17 | 34 | |
| 18 | 35 | [Ollama](https://ollama.com) enables fully offline operation with no API keys required. PlanOpticon connects via Ollama's OpenAI-compatible API. |
| 19 | 36 | |
| @@ -51,31 +68,43 @@ | ||
| 51 | 68 | # Automatically discovers models from all configured providers + Ollama |
| 52 | 69 | ``` |
| 53 | 70 | |
| 54 | 71 | ## Routing preferences |
| 55 | 72 | |
| 56 | -Each task type has a default preference order: | |
| 73 | +Each task type has a default preference order (cheapest first): | |
| 57 | 74 | |
| 58 | 75 | | Task | Preference | |
| 59 | 76 | |------|-----------| |
| 60 | -| Vision | Gemini Flash → GPT-4o → Claude Sonnet → Ollama | | |
| 61 | -| Chat | Claude Sonnet → GPT-4o → Gemini Flash → Ollama | | |
| 77 | +| Vision | Gemini Flash → GPT-4o-mini → Claude Haiku → Ollama | | |
| 78 | +| Chat | Claude Haiku → GPT-4o-mini → Gemini Flash → Ollama | | |
| 62 | 79 | | Transcription | Local Whisper → Whisper-1 → Gemini Flash | |
| 63 | 80 | |
| 64 | -Ollama acts as the last-resort fallback — if no cloud API keys are set but Ollama is running, it is used automatically. | |
| 81 | +Ollama acts as the last-resort fallback -- if no cloud API keys are set but Ollama is running, it is used automatically. | |
| 65 | 82 | |
| 66 | 83 | ## Manual override |
| 67 | 84 | |
| 68 | 85 | ```python |
| 69 | 86 | pm = ProviderManager( |
| 70 | 87 | vision_model="gpt-4o", |
| 71 | - chat_model="claude-sonnet-4-5-20250929", | |
| 88 | + chat_model="claude-sonnet-4-20250514", | |
| 72 | 89 | provider="openai", # Force a specific provider |
| 73 | 90 | ) |
| 91 | + | |
| 92 | +# Use a cheap model for bulk processing | |
| 93 | +pm = ProviderManager( | |
| 94 | + chat_model="claude-haiku-3-5-20241022", | |
| 95 | + vision_model="gemini-2.0-flash", | |
| 96 | +) | |
| 74 | 97 | |
| 75 | 98 | # Or use Ollama for fully offline processing |
| 76 | 99 | pm = ProviderManager(provider="ollama") |
| 100 | + | |
| 101 | +# Use Azure OpenAI | |
| 102 | +pm = ProviderManager(provider="azure") | |
| 103 | + | |
| 104 | +# Use Together AI for open-source models | |
| 105 | +pm = ProviderManager(provider="together", chat_model="meta-llama/Llama-3.3-70B-Instruct-Turbo") | |
| 77 | 106 | ``` |
| 78 | 107 | |
| 79 | 108 | ## BaseProvider interface |
| 80 | 109 | |
| 81 | 110 | All providers implement: |
| 82 | 111 |
| --- docs/architecture/providers.md | |
| +++ docs/architecture/providers.md | |
| @@ -1,19 +1,36 @@ | |
| 1 | # Provider System |
| 2 | |
| 3 | ## Overview |
| 4 | |
| 5 | PlanOpticon supports multiple AI providers through a unified abstraction layer. |
| 6 | |
| 7 | ## Supported providers |
| 8 | |
| 9 | | Provider | Chat | Vision | Transcription | |
| 10 | |----------|------|--------|--------------| |
| 11 | | OpenAI | GPT-4o, GPT-4 | GPT-4o | Whisper-1 | |
| 12 | | Anthropic | Claude Sonnet/Opus | Claude Sonnet/Opus | — | |
| 13 | | Google Gemini | Gemini Flash/Pro | Gemini Flash/Pro | Gemini Flash | |
| 14 | | Ollama (local) | Any installed model | llava, moondream, etc. | — (use local Whisper) | |
| 15 | |
| 16 | ## Ollama (offline mode) |
| 17 | |
| 18 | [Ollama](https://ollama.com) enables fully offline operation with no API keys required. PlanOpticon connects via Ollama's OpenAI-compatible API. |
| 19 | |
| @@ -51,31 +68,43 @@ | |
| 51 | # Automatically discovers models from all configured providers + Ollama |
| 52 | ``` |
| 53 | |
| 54 | ## Routing preferences |
| 55 | |
| 56 | Each task type has a default preference order: |
| 57 | |
| 58 | | Task | Preference | |
| 59 | |------|-----------| |
| 60 | | Vision | Gemini Flash → GPT-4o → Claude Sonnet → Ollama | |
| 61 | | Chat | Claude Sonnet → GPT-4o → Gemini Flash → Ollama | |
| 62 | | Transcription | Local Whisper → Whisper-1 → Gemini Flash | |
| 63 | |
| 64 | Ollama acts as the last-resort fallback — if no cloud API keys are set but Ollama is running, it is used automatically. |
| 65 | |
| 66 | ## Manual override |
| 67 | |
| 68 | ```python |
| 69 | pm = ProviderManager( |
| 70 | vision_model="gpt-4o", |
| 71 | chat_model="claude-sonnet-4-5-20250929", |
| 72 | provider="openai", # Force a specific provider |
| 73 | ) |
| 74 | |
| 75 | # Or use Ollama for fully offline processing |
| 76 | pm = ProviderManager(provider="ollama") |
| 77 | ``` |
| 78 | |
| 79 | ## BaseProvider interface |
| 80 | |
| 81 | All providers implement: |
| 82 |
| --- docs/architecture/providers.md | |
| +++ docs/architecture/providers.md | |
| @@ -1,19 +1,36 @@ | |
| 1 | # Provider System |
| 2 | |
| 3 | ## Overview |
| 4 | |
| 5 | PlanOpticon supports multiple AI providers through a unified abstraction layer. Default models favor cost-effective options (Haiku, GPT-4o-mini, Gemini Flash) for routine tasks, with more capable models available when needed. |
| 6 | |
| 7 | ## Supported providers |
| 8 | |
| 9 | | Provider | Chat | Vision | Transcription | Env Variable | |
| 10 | |----------|------|--------|--------------|--------------| |
| 11 | | OpenAI | GPT-4o-mini, GPT-4o | GPT-4o-mini, GPT-4o | Whisper-1 | `OPENAI_API_KEY` | |
| 12 | | Anthropic | Claude Haiku, Sonnet, Opus | Claude Haiku, Sonnet, Opus | — | `ANTHROPIC_API_KEY` | |
| 13 | | Google Gemini | Gemini Flash, Pro | Gemini Flash, Pro | Gemini Flash | `GEMINI_API_KEY` | |
| 14 | | Azure OpenAI | GPT-4o-mini, GPT-4o | GPT-4o-mini, GPT-4o | Whisper-1 | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT` | |
| 15 | | Together AI | Llama, Mixtral, etc. | Llava | — | `TOGETHER_API_KEY` | |
| 16 | | Fireworks AI | Llama, Mixtral, etc. | Llava | — | `FIREWORKS_API_KEY` | |
| 17 | | Cerebras | Llama (fast inference) | — | — | `CEREBRAS_API_KEY` | |
| 18 | | xAI | Grok | Grok | — | `XAI_API_KEY` | |
| 19 | | Ollama (local) | Any installed model | llava, moondream, etc. | — (use local Whisper) | `OLLAMA_HOST` | |
| 20 | |
| 21 | ## Default models |
| 22 | |
| 23 | PlanOpticon defaults to cheap, fast models for cost efficiency: |
| 24 | |
| 25 | | Task | Default model | |
| 26 | |------|--------------| |
| 27 | | Vision (diagrams) | Gemini Flash | |
| 28 | | Chat (analysis) | Claude Haiku | |
| 29 | | Transcription | Local Whisper (fallback: Whisper-1) | |
| 30 | |
| 31 | Use `--vision-model` and `--chat-model` to override with more capable models when needed (e.g., `--chat-model claude-sonnet-4-20250514` for complex analysis). |
| 32 | |
| 33 | ## Ollama (offline mode) |
| 34 | |
| 35 | [Ollama](https://ollama.com) enables fully offline operation with no API keys required. PlanOpticon connects via Ollama's OpenAI-compatible API. |
| 36 | |
| @@ -51,31 +68,43 @@ | |
| 68 | # Automatically discovers models from all configured providers + Ollama |
| 69 | ``` |
| 70 | |
| 71 | ## Routing preferences |
| 72 | |
| 73 | Each task type has a default preference order (cheapest first): |
| 74 | |
| 75 | | Task | Preference | |
| 76 | |------|-----------| |
| 77 | | Vision | Gemini Flash → GPT-4o-mini → Claude Haiku → Ollama | |
| 78 | | Chat | Claude Haiku → GPT-4o-mini → Gemini Flash → Ollama | |
| 79 | | Transcription | Local Whisper → Whisper-1 → Gemini Flash | |
| 80 | |
| 81 | Ollama acts as the last-resort fallback -- if no cloud API keys are set but Ollama is running, it is used automatically. |
| 82 | |
| 83 | ## Manual override |
| 84 | |
| 85 | ```python |
| 86 | pm = ProviderManager( |
| 87 | vision_model="gpt-4o", |
| 88 | chat_model="claude-sonnet-4-20250514", |
| 89 | provider="openai", # Force a specific provider |
| 90 | ) |
| 91 | |
| 92 | # Use a cheap model for bulk processing |
| 93 | pm = ProviderManager( |
| 94 | chat_model="claude-haiku-3-5-20241022", |
| 95 | vision_model="gemini-2.0-flash", |
| 96 | ) |
| 97 | |
| 98 | # Or use Ollama for fully offline processing |
| 99 | pm = ProviderManager(provider="ollama") |
| 100 | |
| 101 | # Use Azure OpenAI |
| 102 | pm = ProviderManager(provider="azure") |
| 103 | |
| 104 | # Use Together AI for open-source models |
| 105 | pm = ProviderManager(provider="together", chat_model="meta-llama/Llama-3.3-70B-Instruct-Turbo") |
| 106 | ``` |
| 107 | |
| 108 | ## BaseProvider interface |
| 109 | |
| 110 | All providers implement: |
| 111 |
+561
-11
| --- docs/cli-reference.md | ||
| +++ docs/cli-reference.md | ||
| @@ -1,6 +1,20 @@ | ||
| 1 | 1 | # CLI Reference |
| 2 | + | |
| 3 | +## Global options | |
| 4 | + | |
| 5 | +These options are available on all commands. | |
| 6 | + | |
| 7 | +| Option | Description | | |
| 8 | +|--------|-------------| | |
| 9 | +| `-v`, `--verbose` | Enable debug-level logging | | |
| 10 | +| `-C`, `--chat` | Enable chat mode (interactive follow-up after command completes) | | |
| 11 | +| `-I`, `--interactive` | Enable interactive REPL mode | | |
| 12 | +| `--version` | Show version and exit | | |
| 13 | +| `--help` | Show help and exit | | |
| 14 | + | |
| 15 | +--- | |
| 2 | 16 | |
| 3 | 17 | ## `planopticon analyze` |
| 4 | 18 | |
| 5 | 19 | Analyze a single video and extract structured knowledge. |
| 6 | 20 | |
| @@ -95,39 +109,575 @@ | ||
| 95 | 109 | | `-p`, `--provider` | `auto\|openai\|anthropic\|gemini\|ollama` | `auto` | API provider | |
| 96 | 110 | | `--vision-model` | TEXT | auto | Override vision model | |
| 97 | 111 | | `--chat-model` | TEXT | auto | Override chat model | |
| 98 | 112 | |
| 99 | 113 | --- |
| 114 | + | |
| 115 | +## `planopticon companion` | |
| 116 | + | |
| 117 | +Interactive knowledge base companion. Opens a REPL for conversational exploration of your knowledge base. | |
| 118 | + | |
| 119 | +```bash | |
| 120 | +planopticon companion [OPTIONS] | |
| 121 | +``` | |
| 122 | + | |
| 123 | +| Option | Type | Default | Description | | |
| 124 | +|--------|------|---------|-------------| | |
| 125 | +| `--kb` | PATH | auto-detect | Path to knowledge base directory | | |
| 126 | +| `-p`, `--provider` | TEXT | `auto` | AI provider | | |
| 127 | +| `--chat-model` | TEXT | auto | Override chat model | | |
| 128 | + | |
| 129 | +**Examples:** | |
| 130 | + | |
| 131 | +```bash | |
| 132 | +# Start companion with auto-detected knowledge base | |
| 133 | +planopticon companion | |
| 134 | + | |
| 135 | +# Point to a specific knowledge base | |
| 136 | +planopticon companion --kb ./my-kb | |
| 137 | + | |
| 138 | +# Use a specific provider | |
| 139 | +planopticon companion --kb ./kb --provider anthropic --chat-model claude-sonnet-4-20250514 | |
| 140 | +``` | |
| 141 | + | |
| 142 | +--- | |
| 143 | + | |
| 144 | +## `planopticon agent` | |
| 145 | + | |
| 146 | +Planning agent with adaptive analysis. Runs an agentic loop that reasons about your knowledge base, plans actions, and executes them. | |
| 147 | + | |
| 148 | +```bash | |
| 149 | +planopticon agent [OPTIONS] | |
| 150 | +``` | |
| 151 | + | |
| 152 | +| Option | Type | Default | Description | | |
| 153 | +|--------|------|---------|-------------| | |
| 154 | +| `--kb` | PATH | auto-detect | Path to knowledge base directory | | |
| 155 | +| `-I`, `--interactive` | FLAG | off | Interactive mode (ask before each action) | | |
| 156 | +| `--export` | PATH | none | Export agent results to a file | | |
| 157 | +| `-p`, `--provider` | TEXT | `auto` | AI provider | | |
| 158 | +| `--chat-model` | TEXT | auto | Override chat model | | |
| 159 | + | |
| 160 | +**Examples:** | |
| 161 | + | |
| 162 | +```bash | |
| 163 | +# Run the agent interactively | |
| 164 | +planopticon agent --kb ./kb --interactive | |
| 165 | + | |
| 166 | +# Run agent and export results | |
| 167 | +planopticon agent --kb ./kb --export ./plan.md | |
| 168 | + | |
| 169 | +# Use a specific model | |
| 170 | +planopticon agent --kb ./kb --provider openai --chat-model gpt-4o | |
| 171 | +``` | |
| 172 | + | |
| 173 | +--- | |
| 174 | + | |
| 175 | +## `planopticon query` | |
| 176 | + | |
| 177 | +Query the knowledge graph directly or with natural language. | |
| 178 | + | |
| 179 | +```bash | |
| 180 | +planopticon query [OPTIONS] [QUERY] | |
| 181 | +``` | |
| 182 | + | |
| 183 | +| Option | Type | Default | Description | | |
| 184 | +|--------|------|---------|-------------| | |
| 185 | +| `--db-path` | PATH | auto-detect | Path to knowledge graph database | | |
| 186 | +| `--mode` | `direct\|agentic` | auto | Query mode (direct for structured, agentic for natural language) | | |
| 187 | +| `--format` | `text\|json\|mermaid` | `text` | Output format | | |
| 188 | +| `-I`, `--interactive` | FLAG | off | Interactive REPL mode | | |
| 189 | + | |
| 190 | +**Examples:** | |
| 191 | + | |
| 192 | +```bash | |
| 193 | +# Show graph stats | |
| 194 | +planopticon query stats | |
| 195 | + | |
| 196 | +# List entities by type | |
| 197 | +planopticon query "entities --type technology" | |
| 198 | +planopticon query "entities --type person" | |
| 199 | + | |
| 200 | +# Find neighbors of an entity | |
| 201 | +planopticon query "neighbors Alice" | |
| 202 | + | |
| 203 | +# List relationships | |
| 204 | +planopticon query "relationships --source Alice" | |
| 205 | + | |
| 206 | +# Natural language query (requires API key) | |
| 207 | +planopticon query "What technologies were discussed?" | |
| 208 | + | |
| 209 | +# Output as Mermaid diagram | |
| 210 | +planopticon query --format mermaid "neighbors ProjectX" | |
| 211 | + | |
| 212 | +# Output as JSON | |
| 213 | +planopticon query --format json stats | |
| 214 | + | |
| 215 | +# Interactive REPL | |
| 216 | +planopticon query -I | |
| 217 | +``` | |
| 218 | + | |
| 219 | +--- | |
| 220 | + | |
| 221 | +## `planopticon ingest` | |
| 222 | + | |
| 223 | +Ingest documents and files into a knowledge graph. | |
| 224 | + | |
| 225 | +```bash | |
| 226 | +planopticon ingest [OPTIONS] INPUT | |
| 227 | +``` | |
| 228 | + | |
| 229 | +| Option | Type | Default | Description | | |
| 230 | +|--------|------|---------|-------------| | |
| 231 | +| `--output` | PATH | `./knowledge-base` | Output directory for the knowledge base | | |
| 232 | +| `--db-path` | PATH | auto | Path to existing knowledge graph database to merge into | | |
| 233 | +| `--recursive` | FLAG | off | Recursively process directories | | |
| 234 | +| `-p`, `--provider` | TEXT | `auto` | AI provider | | |
| 235 | + | |
| 236 | +**Examples:** | |
| 237 | + | |
| 238 | +```bash | |
| 239 | +# Ingest a single file | |
| 240 | +planopticon ingest ./meeting-notes.md --output ./kb | |
| 241 | + | |
| 242 | +# Ingest a directory recursively | |
| 243 | +planopticon ingest ./docs/ --output ./kb --recursive | |
| 244 | + | |
| 245 | +# Merge into an existing knowledge graph | |
| 246 | +planopticon ingest ./new-notes/ --db-path ./kb/knowledge_graph.db --recursive | |
| 247 | +``` | |
| 248 | + | |
| 249 | +--- | |
| 100 | 250 | |
| 101 | 251 | ## `planopticon auth` |
| 102 | 252 | |
| 103 | -Authenticate with a cloud storage service for batch processing. | |
| 253 | +Authenticate with cloud services via OAuth or API keys. | |
| 104 | 254 | |
| 105 | 255 | ```bash |
| 106 | -planopticon auth SERVICE | |
| 256 | +planopticon auth SERVICE [OPTIONS] | |
| 107 | 257 | ``` |
| 108 | 258 | |
| 109 | 259 | | Argument | Values | Description | |
| 110 | 260 | |----------|--------|-------------| |
| 111 | -| `SERVICE` | `google\|dropbox` | Cloud service to authenticate with | | |
| 261 | +| `SERVICE` | `google\|dropbox\|zoom\|notion\|github\|microsoft` | Cloud service to authenticate with | | |
| 262 | + | |
| 263 | +| Option | Type | Default | Description | | |
| 264 | +|--------|------|---------|-------------| | |
| 265 | +| `--logout` | FLAG | off | Remove stored credentials for the service | | |
| 112 | 266 | |
| 113 | 267 | **Examples:** |
| 114 | 268 | |
| 115 | 269 | ```bash |
| 116 | -# Authenticate with Google Drive (interactive OAuth2) | |
| 270 | +# Authenticate with Google (Drive, Meet, YouTube, etc.) | |
| 117 | 271 | planopticon auth google |
| 118 | 272 | |
| 119 | 273 | # Authenticate with Dropbox |
| 120 | 274 | planopticon auth dropbox |
| 275 | + | |
| 276 | +# Authenticate with Zoom (for recording access) | |
| 277 | +planopticon auth zoom | |
| 278 | + | |
| 279 | +# Authenticate with Notion | |
| 280 | +planopticon auth notion | |
| 281 | + | |
| 282 | +# Authenticate with GitHub | |
| 283 | +planopticon auth github | |
| 284 | + | |
| 285 | +# Authenticate with Microsoft 365 (OneDrive, Teams, etc.) | |
| 286 | +planopticon auth microsoft | |
| 287 | + | |
| 288 | +# Log out of a service | |
| 289 | +planopticon auth google --logout | |
| 290 | +``` | |
| 291 | + | |
| 292 | +--- | |
| 293 | + | |
| 294 | +## `planopticon gws` | |
| 295 | + | |
| 296 | +Google Workspace commands. List, fetch, and ingest content from Google Workspace (Drive, Docs, Sheets, Slides, Meet). | |
| 297 | + | |
| 298 | +### `planopticon gws list` | |
| 299 | + | |
| 300 | +List available files and recordings from Google Workspace. | |
| 301 | + | |
| 302 | +```bash | |
| 303 | +planopticon gws list [OPTIONS] | |
| 304 | +``` | |
| 305 | + | |
| 306 | +| Option | Type | Default | Description | | |
| 307 | +|--------|------|---------|-------------| | |
| 308 | +| `--type` | `drive\|docs\|sheets\|slides\|meet` | all | Filter by content type | | |
| 309 | +| `--folder-id` | TEXT | none | Google Drive folder ID | | |
| 310 | +| `--limit` | INT | 50 | Maximum results to return | | |
| 311 | + | |
| 312 | +### `planopticon gws fetch` | |
| 313 | + | |
| 314 | +Download content from Google Workspace. | |
| 315 | + | |
| 316 | +```bash | |
| 317 | +planopticon gws fetch [OPTIONS] RESOURCE_ID | |
| 318 | +``` | |
| 319 | + | |
| 320 | +| Option | Type | Default | Description | | |
| 321 | +|--------|------|---------|-------------| | |
| 322 | +| `--output` | PATH | `./downloads` | Output directory | | |
| 323 | +| `--format` | TEXT | auto | Export format (pdf, docx, etc.) | | |
| 324 | + | |
| 325 | +### `planopticon gws ingest` | |
| 326 | + | |
| 327 | +Ingest Google Workspace content directly into a knowledge graph. | |
| 328 | + | |
| 329 | +```bash | |
| 330 | +planopticon gws ingest [OPTIONS] | |
| 331 | +``` | |
| 332 | + | |
| 333 | +| Option | Type | Default | Description | | |
| 334 | +|--------|------|---------|-------------| | |
| 335 | +| `--folder-id` | TEXT | none | Google Drive folder ID | | |
| 336 | +| `--output` | PATH | `./knowledge-base` | Knowledge base output directory | | |
| 337 | +| `--recursive` | FLAG | off | Recurse into subfolders | | |
| 338 | + | |
| 339 | +**Examples:** | |
| 340 | + | |
| 341 | +```bash | |
| 342 | +# List all Google Workspace files | |
| 343 | +planopticon gws list | |
| 344 | + | |
| 345 | +# List only Google Docs | |
| 346 | +planopticon gws list --type docs | |
| 347 | + | |
| 348 | +# Fetch a specific file | |
| 349 | +planopticon gws fetch abc123def --output ./downloads | |
| 350 | + | |
| 351 | +# Ingest an entire Drive folder into a knowledge base | |
| 352 | +planopticon gws ingest --folder-id abc123 --output ./kb --recursive | |
| 353 | +``` | |
| 354 | + | |
| 355 | +--- | |
| 356 | + | |
| 357 | +## `planopticon m365` | |
| 358 | + | |
| 359 | +Microsoft 365 commands. List, fetch, and ingest content from Microsoft 365 (OneDrive, SharePoint, Teams, Outlook). | |
| 360 | + | |
| 361 | +### `planopticon m365 list` | |
| 362 | + | |
| 363 | +List available files and recordings from Microsoft 365. | |
| 364 | + | |
| 365 | +```bash | |
| 366 | +planopticon m365 list [OPTIONS] | |
| 367 | +``` | |
| 368 | + | |
| 369 | +| Option | Type | Default | Description | | |
| 370 | +|--------|------|---------|-------------| | |
| 371 | +| `--type` | `onedrive\|sharepoint\|teams\|outlook` | all | Filter by content type | | |
| 372 | +| `--site` | TEXT | none | SharePoint site name | | |
| 373 | +| `--limit` | INT | 50 | Maximum results to return | | |
| 374 | + | |
| 375 | +### `planopticon m365 fetch` | |
| 376 | + | |
| 377 | +Download content from Microsoft 365. | |
| 378 | + | |
| 379 | +```bash | |
| 380 | +planopticon m365 fetch [OPTIONS] RESOURCE_ID | |
| 381 | +``` | |
| 382 | + | |
| 383 | +| Option | Type | Default | Description | | |
| 384 | +|--------|------|---------|-------------| | |
| 385 | +| `--output` | PATH | `./downloads` | Output directory | | |
| 386 | + | |
| 387 | +### `planopticon m365 ingest` | |
| 388 | + | |
| 389 | +Ingest Microsoft 365 content directly into a knowledge graph. | |
| 390 | + | |
| 391 | +```bash | |
| 392 | +planopticon m365 ingest [OPTIONS] | |
| 393 | +``` | |
| 394 | + | |
| 395 | +| Option | Type | Default | Description | | |
| 396 | +|--------|------|---------|-------------| | |
| 397 | +| `--site` | TEXT | none | SharePoint site name | | |
| 398 | +| `--path` | TEXT | `/` | Folder path in OneDrive/SharePoint | | |
| 399 | +| `--output` | PATH | `./knowledge-base` | Knowledge base output directory | | |
| 400 | +| `--recursive` | FLAG | off | Recurse into subfolders | | |
| 401 | + | |
| 402 | +**Examples:** | |
| 403 | + | |
| 404 | +```bash | |
| 405 | +# List all Microsoft 365 content | |
| 406 | +planopticon m365 list | |
| 407 | + | |
| 408 | +# List only Teams recordings | |
| 409 | +planopticon m365 list --type teams | |
| 410 | + | |
| 411 | +# Fetch a specific file | |
| 412 | +planopticon m365 fetch item-id-123 --output ./downloads | |
| 413 | + | |
| 414 | +# Ingest SharePoint content | |
| 415 | +planopticon m365 ingest --site "Engineering" --path "/Shared Documents" --output ./kb --recursive | |
| 416 | +``` | |
| 417 | + | |
| 418 | +--- | |
| 419 | + | |
| 420 | +## `planopticon recordings` | |
| 421 | + | |
| 422 | +List meeting recordings from video conferencing platforms. | |
| 423 | + | |
| 424 | +### `planopticon recordings zoom-list` | |
| 425 | + | |
| 426 | +List Zoom cloud recordings. | |
| 427 | + | |
| 428 | +```bash | |
| 429 | +planopticon recordings zoom-list [OPTIONS] | |
| 430 | +``` | |
| 431 | + | |
| 432 | +| Option | Type | Default | Description | | |
| 433 | +|--------|------|---------|-------------| | |
| 434 | +| `--from` | DATE | 30 days ago | Start date (YYYY-MM-DD) | | |
| 435 | +| `--to` | DATE | today | End date (YYYY-MM-DD) | | |
| 436 | +| `--limit` | INT | 50 | Maximum results | | |
| 437 | + | |
| 438 | +### `planopticon recordings teams-list` | |
| 439 | + | |
| 440 | +List Microsoft Teams meeting recordings. | |
| 441 | + | |
| 442 | +```bash | |
| 443 | +planopticon recordings teams-list [OPTIONS] | |
| 444 | +``` | |
| 445 | + | |
| 446 | +| Option | Type | Default | Description | | |
| 447 | +|--------|------|---------|-------------| | |
| 448 | +| `--from` | DATE | 30 days ago | Start date (YYYY-MM-DD) | | |
| 449 | +| `--to` | DATE | today | End date (YYYY-MM-DD) | | |
| 450 | +| `--limit` | INT | 50 | Maximum results | | |
| 451 | + | |
| 452 | +### `planopticon recordings meet-list` | |
| 453 | + | |
| 454 | +List Google Meet recordings. | |
| 455 | + | |
| 456 | +```bash | |
| 457 | +planopticon recordings meet-list [OPTIONS] | |
| 458 | +``` | |
| 459 | + | |
| 460 | +| Option | Type | Default | Description | | |
| 461 | +|--------|------|---------|-------------| | |
| 462 | +| `--from` | DATE | 30 days ago | Start date (YYYY-MM-DD) | | |
| 463 | +| `--to` | DATE | today | End date (YYYY-MM-DD) | | |
| 464 | +| `--limit` | INT | 50 | Maximum results | | |
| 465 | + | |
| 466 | +**Examples:** | |
| 467 | + | |
| 468 | +```bash | |
| 469 | +# List recent Zoom recordings | |
| 470 | +planopticon recordings zoom-list | |
| 471 | + | |
| 472 | +# List Teams recordings from a specific date range | |
| 473 | +planopticon recordings teams-list --from 2026-01-01 --to 2026-02-01 | |
| 474 | + | |
| 475 | +# List Google Meet recordings | |
| 476 | +planopticon recordings meet-list --limit 10 | |
| 477 | +``` | |
| 478 | + | |
| 479 | +--- | |
| 480 | + | |
| 481 | +## `planopticon export` | |
| 482 | + | |
| 483 | +Export knowledge base content to various formats. | |
| 484 | + | |
| 485 | +### `planopticon export markdown` | |
| 486 | + | |
| 487 | +Export knowledge base as Markdown files. | |
| 488 | + | |
| 489 | +```bash | |
| 490 | +planopticon export markdown [OPTIONS] | |
| 491 | +``` | |
| 492 | + | |
| 493 | +| Option | Type | Default | Description | | |
| 494 | +|--------|------|---------|-------------| | |
| 495 | +| `--input` | PATH | auto-detect | Knowledge base path | | |
| 496 | +| `--output` | PATH | `./export` | Output directory | | |
| 497 | + | |
| 498 | +### `planopticon export obsidian` | |
| 499 | + | |
| 500 | +Export knowledge base as an Obsidian vault with wikilinks and graph metadata. | |
| 501 | + | |
| 502 | +```bash | |
| 503 | +planopticon export obsidian [OPTIONS] | |
| 504 | +``` | |
| 505 | + | |
| 506 | +| Option | Type | Default | Description | | |
| 507 | +|--------|------|---------|-------------| | |
| 508 | +| `--input` | PATH | auto-detect | Knowledge base path | | |
| 509 | +| `--output` | PATH | `./obsidian-vault` | Output vault directory | | |
| 510 | + | |
| 511 | +### `planopticon export notion` | |
| 512 | + | |
| 513 | +Export knowledge base to Notion. | |
| 514 | + | |
| 515 | +```bash | |
| 516 | +planopticon export notion [OPTIONS] | |
| 517 | +``` | |
| 518 | + | |
| 519 | +| Option | Type | Default | Description | | |
| 520 | +|--------|------|---------|-------------| | |
| 521 | +| `--input` | PATH | auto-detect | Knowledge base path | | |
| 522 | +| `--parent-page` | TEXT | none | Notion parent page ID | | |
| 523 | + | |
| 524 | +### `planopticon export exchange` | |
| 525 | + | |
| 526 | +Export knowledge base as PlanOpticon Exchange Format (JSON). | |
| 527 | + | |
| 528 | +```bash | |
| 529 | +planopticon export exchange [OPTIONS] | |
| 530 | +``` | |
| 531 | + | |
| 532 | +| Option | Type | Default | Description | | |
| 533 | +|--------|------|---------|-------------| | |
| 534 | +| `--input` | PATH | auto-detect | Knowledge base path | | |
| 535 | +| `--output` | PATH | `./exchange.json` | Output file path | | |
| 536 | + | |
| 537 | +**Examples:** | |
| 538 | + | |
| 539 | +```bash | |
| 540 | +# Export to Markdown | |
| 541 | +planopticon export markdown --input ./kb --output ./docs | |
| 542 | + | |
| 543 | +# Export to Obsidian vault | |
| 544 | +planopticon export obsidian --input ./kb --output ~/Obsidian/PlanOpticon | |
| 545 | + | |
| 546 | +# Export to Notion | |
| 547 | +planopticon export notion --input ./kb --parent-page abc123 | |
| 548 | + | |
| 549 | +# Export as exchange format for interoperability | |
| 550 | +planopticon export exchange --input ./kb --output ./export.json | |
| 551 | +``` | |
| 552 | + | |
| 553 | +--- | |
| 554 | + | |
| 555 | +## `planopticon wiki` | |
| 556 | + | |
| 557 | +Generate and publish wiki documentation from your knowledge base. | |
| 558 | + | |
| 559 | +### `planopticon wiki generate` | |
| 560 | + | |
| 561 | +Generate a static wiki site from the knowledge base. | |
| 562 | + | |
| 563 | +```bash | |
| 564 | +planopticon wiki generate [OPTIONS] | |
| 565 | +``` | |
| 566 | + | |
| 567 | +| Option | Type | Default | Description | | |
| 568 | +|--------|------|---------|-------------| | |
| 569 | +| `--input` | PATH | auto-detect | Knowledge base path | | |
| 570 | +| `--output` | PATH | `./wiki` | Output directory | | |
| 571 | + | |
| 572 | +### `planopticon wiki push` | |
| 573 | + | |
| 574 | +Push a generated wiki to a remote target (e.g., GitHub Wiki, Confluence). | |
| 575 | + | |
| 576 | +```bash | |
| 577 | +planopticon wiki push [OPTIONS] | |
| 121 | 578 | ``` |
| 122 | 579 | |
| 123 | -After authentication, use `planopticon batch --source gdrive` or `--source dropbox` to process cloud videos. | |
| 580 | +| Option | Type | Default | Description | | |
| 581 | +|--------|------|---------|-------------| | |
| 582 | +| `--input` | PATH | `./wiki` | Wiki directory to push | | |
| 583 | +| `--target` | TEXT | *required* | Push target (e.g., `github://org/repo`, `confluence://space`) | | |
| 584 | + | |
| 585 | +**Examples:** | |
| 586 | + | |
| 587 | +```bash | |
| 588 | +# Generate a wiki from the knowledge base | |
| 589 | +planopticon wiki generate --input ./kb --output ./wiki | |
| 590 | + | |
| 591 | +# Push wiki to GitHub | |
| 592 | +planopticon wiki push --input ./wiki --target "github://ConflictHQ/project-wiki" | |
| 593 | +``` | |
| 124 | 594 | |
| 125 | 595 | --- |
| 126 | 596 | |
| 127 | -## Global options | |
| 597 | +## `planopticon kg` | |
| 598 | + | |
| 599 | +Knowledge graph management commands. | |
| 600 | + | |
| 601 | +### `planopticon kg convert` | |
| 602 | + | |
| 603 | +Convert a knowledge graph between formats. | |
| 604 | + | |
| 605 | +```bash | |
| 606 | +planopticon kg convert [OPTIONS] | |
| 607 | +``` | |
| 608 | + | |
| 609 | +| Option | Type | Default | Description | | |
| 610 | +|--------|------|---------|-------------| | |
| 611 | +| `--input` | PATH | *required* | Input knowledge graph file | | |
| 612 | +| `--output` | PATH | *required* | Output file path | | |
| 613 | +| `--format` | `json\|db\|graphml\|csv` | auto (from extension) | Target format | | |
| 614 | + | |
| 615 | +### `planopticon kg sync` | |
| 616 | + | |
| 617 | +Synchronize two knowledge graphs (merge new data). | |
| 618 | + | |
| 619 | +```bash | |
| 620 | +planopticon kg sync [OPTIONS] | |
| 621 | +``` | |
| 622 | + | |
| 623 | +| Option | Type | Default | Description | | |
| 624 | +|--------|------|---------|-------------| | |
| 625 | +| `--source` | PATH | *required* | Source knowledge graph | | |
| 626 | +| `--target` | PATH | *required* | Target knowledge graph to merge into | | |
| 627 | + | |
| 628 | +### `planopticon kg inspect` | |
| 629 | + | |
| 630 | +Inspect a knowledge graph and display statistics. | |
| 631 | + | |
| 632 | +```bash | |
| 633 | +planopticon kg inspect [OPTIONS] [PATH] | |
| 634 | +``` | |
| 635 | + | |
| 636 | +| Option | Type | Default | Description | | |
| 637 | +|--------|------|---------|-------------| | |
| 638 | +| `PATH` | PATH | auto-detect | Knowledge graph file | | |
| 639 | + | |
| 640 | +### `planopticon kg classify` | |
| 641 | + | |
| 642 | +Classify and tag entities in a knowledge graph. | |
| 643 | + | |
| 644 | +```bash | |
| 645 | +planopticon kg classify [OPTIONS] | |
| 646 | +``` | |
| 647 | + | |
| 648 | +| Option | Type | Default | Description | | |
| 649 | +|--------|------|---------|-------------| | |
| 650 | +| `--db-path` | PATH | auto-detect | Knowledge graph database | | |
| 651 | +| `-p`, `--provider` | TEXT | `auto` | AI provider for classification | | |
| 652 | + | |
| 653 | +### `planopticon kg from-exchange` | |
| 654 | + | |
| 655 | +Import a knowledge graph from PlanOpticon Exchange Format. | |
| 656 | + | |
| 657 | +```bash | |
| 658 | +planopticon kg from-exchange [OPTIONS] INPUT | |
| 659 | +``` | |
| 660 | + | |
| 661 | +| Option | Type | Default | Description | | |
| 662 | +|--------|------|---------|-------------| | |
| 663 | +| `INPUT` | PATH | *required* | Exchange format JSON file | | |
| 664 | +| `--output` | PATH | `./knowledge-base` | Output knowledge base directory | | |
| 665 | + | |
| 666 | +**Examples:** | |
| 667 | + | |
| 668 | +```bash | |
| 669 | +# Convert JSON knowledge graph to FalkorDB format | |
| 670 | +planopticon kg convert --input ./kg.json --output ./kg.db | |
| 671 | + | |
| 672 | +# Merge two knowledge graphs | |
| 673 | +planopticon kg sync --source ./new-kg.db --target ./main-kg.db | |
| 674 | + | |
| 675 | +# Inspect a knowledge graph | |
| 676 | +planopticon kg inspect ./knowledge_graph.db | |
| 677 | + | |
| 678 | +# Classify entities with AI | |
| 679 | +planopticon kg classify --db-path ./kg.db --provider anthropic | |
| 128 | 680 | |
| 129 | -| Option | Description | | |
| 130 | -|--------|-------------| | |
| 131 | -| `-v`, `--verbose` | Enable debug-level logging | | |
| 132 | -| `--version` | Show version and exit | | |
| 133 | -| `--help` | Show help and exit | | |
| 681 | +# Import from exchange format | |
| 682 | +planopticon kg from-exchange ./export.json --output ./kb | |
| 683 | +``` | |
| 134 | 684 |
| --- docs/cli-reference.md | |
| +++ docs/cli-reference.md | |
| @@ -1,6 +1,20 @@ | |
| 1 | # CLI Reference |
| 2 | |
| 3 | ## `planopticon analyze` |
| 4 | |
| 5 | Analyze a single video and extract structured knowledge. |
| 6 | |
| @@ -95,39 +109,575 @@ | |
| 95 | | `-p`, `--provider` | `auto\|openai\|anthropic\|gemini\|ollama` | `auto` | API provider | |
| 96 | | `--vision-model` | TEXT | auto | Override vision model | |
| 97 | | `--chat-model` | TEXT | auto | Override chat model | |
| 98 | |
| 99 | --- |
| 100 | |
| 101 | ## `planopticon auth` |
| 102 | |
| 103 | Authenticate with a cloud storage service for batch processing. |
| 104 | |
| 105 | ```bash |
| 106 | planopticon auth SERVICE |
| 107 | ``` |
| 108 | |
| 109 | | Argument | Values | Description | |
| 110 | |----------|--------|-------------| |
| 111 | | `SERVICE` | `google\|dropbox` | Cloud service to authenticate with | |
| 112 | |
| 113 | **Examples:** |
| 114 | |
| 115 | ```bash |
| 116 | # Authenticate with Google Drive (interactive OAuth2) |
| 117 | planopticon auth google |
| 118 | |
| 119 | # Authenticate with Dropbox |
| 120 | planopticon auth dropbox |
| 121 | ``` |
| 122 | |
| 123 | After authentication, use `planopticon batch --source gdrive` or `--source dropbox` to process cloud videos. |
| 124 | |
| 125 | --- |
| 126 | |
| 127 | ## Global options |
| 128 | |
| 129 | | Option | Description | |
| 130 | |--------|-------------| |
| 131 | | `-v`, `--verbose` | Enable debug-level logging | |
| 132 | | `--version` | Show version and exit | |
| 133 | | `--help` | Show help and exit | |
| 134 |
| --- docs/cli-reference.md | |
| +++ docs/cli-reference.md | |
| @@ -1,6 +1,20 @@ | |
| 1 | # CLI Reference |
| 2 | |
| 3 | ## Global options |
| 4 | |
| 5 | These options are available on all commands. |
| 6 | |
| 7 | | Option | Description | |
| 8 | |--------|-------------| |
| 9 | | `-v`, `--verbose` | Enable debug-level logging | |
| 10 | | `-C`, `--chat` | Enable chat mode (interactive follow-up after command completes) | |
| 11 | | `-I`, `--interactive` | Enable interactive REPL mode | |
| 12 | | `--version` | Show version and exit | |
| 13 | | `--help` | Show help and exit | |
| 14 | |
| 15 | --- |
| 16 | |
| 17 | ## `planopticon analyze` |
| 18 | |
| 19 | Analyze a single video and extract structured knowledge. |
| 20 | |
| @@ -95,39 +109,575 @@ | |
| 109 | | `-p`, `--provider` | `auto\|openai\|anthropic\|gemini\|ollama` | `auto` | API provider | |
| 110 | | `--vision-model` | TEXT | auto | Override vision model | |
| 111 | | `--chat-model` | TEXT | auto | Override chat model | |
| 112 | |
| 113 | --- |
| 114 | |
| 115 | ## `planopticon companion` |
| 116 | |
| 117 | Interactive knowledge base companion. Opens a REPL for conversational exploration of your knowledge base. |
| 118 | |
| 119 | ```bash |
| 120 | planopticon companion [OPTIONS] |
| 121 | ``` |
| 122 | |
| 123 | | Option | Type | Default | Description | |
| 124 | |--------|------|---------|-------------| |
| 125 | | `--kb` | PATH | auto-detect | Path to knowledge base directory | |
| 126 | | `-p`, `--provider` | TEXT | `auto` | AI provider | |
| 127 | | `--chat-model` | TEXT | auto | Override chat model | |
| 128 | |
| 129 | **Examples:** |
| 130 | |
| 131 | ```bash |
| 132 | # Start companion with auto-detected knowledge base |
| 133 | planopticon companion |
| 134 | |
| 135 | # Point to a specific knowledge base |
| 136 | planopticon companion --kb ./my-kb |
| 137 | |
| 138 | # Use a specific provider |
| 139 | planopticon companion --kb ./kb --provider anthropic --chat-model claude-sonnet-4-20250514 |
| 140 | ``` |
| 141 | |
| 142 | --- |
| 143 | |
| 144 | ## `planopticon agent` |
| 145 | |
| 146 | Planning agent with adaptive analysis. Runs an agentic loop that reasons about your knowledge base, plans actions, and executes them. |
| 147 | |
| 148 | ```bash |
| 149 | planopticon agent [OPTIONS] |
| 150 | ``` |
| 151 | |
| 152 | | Option | Type | Default | Description | |
| 153 | |--------|------|---------|-------------| |
| 154 | | `--kb` | PATH | auto-detect | Path to knowledge base directory | |
| 155 | | `-I`, `--interactive` | FLAG | off | Interactive mode (ask before each action) | |
| 156 | | `--export` | PATH | none | Export agent results to a file | |
| 157 | | `-p`, `--provider` | TEXT | `auto` | AI provider | |
| 158 | | `--chat-model` | TEXT | auto | Override chat model | |
| 159 | |
| 160 | **Examples:** |
| 161 | |
| 162 | ```bash |
| 163 | # Run the agent interactively |
| 164 | planopticon agent --kb ./kb --interactive |
| 165 | |
| 166 | # Run agent and export results |
| 167 | planopticon agent --kb ./kb --export ./plan.md |
| 168 | |
| 169 | # Use a specific model |
| 170 | planopticon agent --kb ./kb --provider openai --chat-model gpt-4o |
| 171 | ``` |
| 172 | |
| 173 | --- |
| 174 | |
| 175 | ## `planopticon query` |
| 176 | |
| 177 | Query the knowledge graph directly or with natural language. |
| 178 | |
| 179 | ```bash |
| 180 | planopticon query [OPTIONS] [QUERY] |
| 181 | ``` |
| 182 | |
| 183 | | Option | Type | Default | Description | |
| 184 | |--------|------|---------|-------------| |
| 185 | | `--db-path` | PATH | auto-detect | Path to knowledge graph database | |
| 186 | | `--mode` | `direct\|agentic` | auto | Query mode (direct for structured, agentic for natural language) | |
| 187 | | `--format` | `text\|json\|mermaid` | `text` | Output format | |
| 188 | | `-I`, `--interactive` | FLAG | off | Interactive REPL mode | |
| 189 | |
| 190 | **Examples:** |
| 191 | |
| 192 | ```bash |
| 193 | # Show graph stats |
| 194 | planopticon query stats |
| 195 | |
| 196 | # List entities by type |
| 197 | planopticon query "entities --type technology" |
| 198 | planopticon query "entities --type person" |
| 199 | |
| 200 | # Find neighbors of an entity |
| 201 | planopticon query "neighbors Alice" |
| 202 | |
| 203 | # List relationships |
| 204 | planopticon query "relationships --source Alice" |
| 205 | |
| 206 | # Natural language query (requires API key) |
| 207 | planopticon query "What technologies were discussed?" |
| 208 | |
| 209 | # Output as Mermaid diagram |
| 210 | planopticon query --format mermaid "neighbors ProjectX" |
| 211 | |
| 212 | # Output as JSON |
| 213 | planopticon query --format json stats |
| 214 | |
| 215 | # Interactive REPL |
| 216 | planopticon query -I |
| 217 | ``` |
| 218 | |
| 219 | --- |
| 220 | |
| 221 | ## `planopticon ingest` |
| 222 | |
| 223 | Ingest documents and files into a knowledge graph. |
| 224 | |
| 225 | ```bash |
| 226 | planopticon ingest [OPTIONS] INPUT |
| 227 | ``` |
| 228 | |
| 229 | | Option | Type | Default | Description | |
| 230 | |--------|------|---------|-------------| |
| 231 | | `--output` | PATH | `./knowledge-base` | Output directory for the knowledge base | |
| 232 | | `--db-path` | PATH | auto | Path to existing knowledge graph database to merge into | |
| 233 | | `--recursive` | FLAG | off | Recursively process directories | |
| 234 | | `-p`, `--provider` | TEXT | `auto` | AI provider | |
| 235 | |
| 236 | **Examples:** |
| 237 | |
| 238 | ```bash |
| 239 | # Ingest a single file |
| 240 | planopticon ingest ./meeting-notes.md --output ./kb |
| 241 | |
| 242 | # Ingest a directory recursively |
| 243 | planopticon ingest ./docs/ --output ./kb --recursive |
| 244 | |
| 245 | # Merge into an existing knowledge graph |
| 246 | planopticon ingest ./new-notes/ --db-path ./kb/knowledge_graph.db --recursive |
| 247 | ``` |
| 248 | |
| 249 | --- |
| 250 | |
| 251 | ## `planopticon auth` |
| 252 | |
| 253 | Authenticate with cloud services via OAuth or API keys. |
| 254 | |
| 255 | ```bash |
| 256 | planopticon auth SERVICE [OPTIONS] |
| 257 | ``` |
| 258 | |
| 259 | | Argument | Values | Description | |
| 260 | |----------|--------|-------------| |
| 261 | | `SERVICE` | `google\|dropbox\|zoom\|notion\|github\|microsoft` | Cloud service to authenticate with | |
| 262 | |
| 263 | | Option | Type | Default | Description | |
| 264 | |--------|------|---------|-------------| |
| 265 | | `--logout` | FLAG | off | Remove stored credentials for the service | |
| 266 | |
| 267 | **Examples:** |
| 268 | |
| 269 | ```bash |
| 270 | # Authenticate with Google (Drive, Meet, YouTube, etc.) |
| 271 | planopticon auth google |
| 272 | |
| 273 | # Authenticate with Dropbox |
| 274 | planopticon auth dropbox |
| 275 | |
| 276 | # Authenticate with Zoom (for recording access) |
| 277 | planopticon auth zoom |
| 278 | |
| 279 | # Authenticate with Notion |
| 280 | planopticon auth notion |
| 281 | |
| 282 | # Authenticate with GitHub |
| 283 | planopticon auth github |
| 284 | |
| 285 | # Authenticate with Microsoft 365 (OneDrive, Teams, etc.) |
| 286 | planopticon auth microsoft |
| 287 | |
| 288 | # Log out of a service |
| 289 | planopticon auth google --logout |
| 290 | ``` |
| 291 | |
| 292 | --- |
| 293 | |
| 294 | ## `planopticon gws` |
| 295 | |
| 296 | Google Workspace commands. List, fetch, and ingest content from Google Workspace (Drive, Docs, Sheets, Slides, Meet). |
| 297 | |
| 298 | ### `planopticon gws list` |
| 299 | |
| 300 | List available files and recordings from Google Workspace. |
| 301 | |
| 302 | ```bash |
| 303 | planopticon gws list [OPTIONS] |
| 304 | ``` |
| 305 | |
| 306 | | Option | Type | Default | Description | |
| 307 | |--------|------|---------|-------------| |
| 308 | | `--type` | `drive\|docs\|sheets\|slides\|meet` | all | Filter by content type | |
| 309 | | `--folder-id` | TEXT | none | Google Drive folder ID | |
| 310 | | `--limit` | INT | 50 | Maximum results to return | |
| 311 | |
| 312 | ### `planopticon gws fetch` |
| 313 | |
| 314 | Download content from Google Workspace. |
| 315 | |
| 316 | ```bash |
| 317 | planopticon gws fetch [OPTIONS] RESOURCE_ID |
| 318 | ``` |
| 319 | |
| 320 | | Option | Type | Default | Description | |
| 321 | |--------|------|---------|-------------| |
| 322 | | `--output` | PATH | `./downloads` | Output directory | |
| 323 | | `--format` | TEXT | auto | Export format (pdf, docx, etc.) | |
| 324 | |
| 325 | ### `planopticon gws ingest` |
| 326 | |
| 327 | Ingest Google Workspace content directly into a knowledge graph. |
| 328 | |
| 329 | ```bash |
| 330 | planopticon gws ingest [OPTIONS] |
| 331 | ``` |
| 332 | |
| 333 | | Option | Type | Default | Description | |
| 334 | |--------|------|---------|-------------| |
| 335 | | `--folder-id` | TEXT | none | Google Drive folder ID | |
| 336 | | `--output` | PATH | `./knowledge-base` | Knowledge base output directory | |
| 337 | | `--recursive` | FLAG | off | Recurse into subfolders | |
| 338 | |
| 339 | **Examples:** |
| 340 | |
| 341 | ```bash |
| 342 | # List all Google Workspace files |
| 343 | planopticon gws list |
| 344 | |
| 345 | # List only Google Docs |
| 346 | planopticon gws list --type docs |
| 347 | |
| 348 | # Fetch a specific file |
| 349 | planopticon gws fetch abc123def --output ./downloads |
| 350 | |
| 351 | # Ingest an entire Drive folder into a knowledge base |
| 352 | planopticon gws ingest --folder-id abc123 --output ./kb --recursive |
| 353 | ``` |
| 354 | |
| 355 | --- |
| 356 | |
| 357 | ## `planopticon m365` |
| 358 | |
| 359 | Microsoft 365 commands. List, fetch, and ingest content from Microsoft 365 (OneDrive, SharePoint, Teams, Outlook). |
| 360 | |
| 361 | ### `planopticon m365 list` |
| 362 | |
| 363 | List available files and recordings from Microsoft 365. |
| 364 | |
| 365 | ```bash |
| 366 | planopticon m365 list [OPTIONS] |
| 367 | ``` |
| 368 | |
| 369 | | Option | Type | Default | Description | |
| 370 | |--------|------|---------|-------------| |
| 371 | | `--type` | `onedrive\|sharepoint\|teams\|outlook` | all | Filter by content type | |
| 372 | | `--site` | TEXT | none | SharePoint site name | |
| 373 | | `--limit` | INT | 50 | Maximum results to return | |
| 374 | |
| 375 | ### `planopticon m365 fetch` |
| 376 | |
| 377 | Download content from Microsoft 365. |
| 378 | |
| 379 | ```bash |
| 380 | planopticon m365 fetch [OPTIONS] RESOURCE_ID |
| 381 | ``` |
| 382 | |
| 383 | | Option | Type | Default | Description | |
| 384 | |--------|------|---------|-------------| |
| 385 | | `--output` | PATH | `./downloads` | Output directory | |
| 386 | |
| 387 | ### `planopticon m365 ingest` |
| 388 | |
| 389 | Ingest Microsoft 365 content directly into a knowledge graph. |
| 390 | |
| 391 | ```bash |
| 392 | planopticon m365 ingest [OPTIONS] |
| 393 | ``` |
| 394 | |
| 395 | | Option | Type | Default | Description | |
| 396 | |--------|------|---------|-------------| |
| 397 | | `--site` | TEXT | none | SharePoint site name | |
| 398 | | `--path` | TEXT | `/` | Folder path in OneDrive/SharePoint | |
| 399 | | `--output` | PATH | `./knowledge-base` | Knowledge base output directory | |
| 400 | | `--recursive` | FLAG | off | Recurse into subfolders | |
| 401 | |
| 402 | **Examples:** |
| 403 | |
| 404 | ```bash |
| 405 | # List all Microsoft 365 content |
| 406 | planopticon m365 list |
| 407 | |
| 408 | # List only Teams recordings |
| 409 | planopticon m365 list --type teams |
| 410 | |
| 411 | # Fetch a specific file |
| 412 | planopticon m365 fetch item-id-123 --output ./downloads |
| 413 | |
| 414 | # Ingest SharePoint content |
| 415 | planopticon m365 ingest --site "Engineering" --path "/Shared Documents" --output ./kb --recursive |
| 416 | ``` |
| 417 | |
| 418 | --- |
| 419 | |
| 420 | ## `planopticon recordings` |
| 421 | |
| 422 | List meeting recordings from video conferencing platforms. |
| 423 | |
| 424 | ### `planopticon recordings zoom-list` |
| 425 | |
| 426 | List Zoom cloud recordings. |
| 427 | |
| 428 | ```bash |
| 429 | planopticon recordings zoom-list [OPTIONS] |
| 430 | ``` |
| 431 | |
| 432 | | Option | Type | Default | Description | |
| 433 | |--------|------|---------|-------------| |
| 434 | | `--from` | DATE | 30 days ago | Start date (YYYY-MM-DD) | |
| 435 | | `--to` | DATE | today | End date (YYYY-MM-DD) | |
| 436 | | `--limit` | INT | 50 | Maximum results | |
| 437 | |
| 438 | ### `planopticon recordings teams-list` |
| 439 | |
| 440 | List Microsoft Teams meeting recordings. |
| 441 | |
| 442 | ```bash |
| 443 | planopticon recordings teams-list [OPTIONS] |
| 444 | ``` |
| 445 | |
| 446 | | Option | Type | Default | Description | |
| 447 | |--------|------|---------|-------------| |
| 448 | | `--from` | DATE | 30 days ago | Start date (YYYY-MM-DD) | |
| 449 | | `--to` | DATE | today | End date (YYYY-MM-DD) | |
| 450 | | `--limit` | INT | 50 | Maximum results | |
| 451 | |
| 452 | ### `planopticon recordings meet-list` |
| 453 | |
| 454 | List Google Meet recordings. |
| 455 | |
| 456 | ```bash |
| 457 | planopticon recordings meet-list [OPTIONS] |
| 458 | ``` |
| 459 | |
| 460 | | Option | Type | Default | Description | |
| 461 | |--------|------|---------|-------------| |
| 462 | | `--from` | DATE | 30 days ago | Start date (YYYY-MM-DD) | |
| 463 | | `--to` | DATE | today | End date (YYYY-MM-DD) | |
| 464 | | `--limit` | INT | 50 | Maximum results | |
| 465 | |
| 466 | **Examples:** |
| 467 | |
| 468 | ```bash |
| 469 | # List recent Zoom recordings |
| 470 | planopticon recordings zoom-list |
| 471 | |
| 472 | # List Teams recordings from a specific date range |
| 473 | planopticon recordings teams-list --from 2026-01-01 --to 2026-02-01 |
| 474 | |
| 475 | # List Google Meet recordings |
| 476 | planopticon recordings meet-list --limit 10 |
| 477 | ``` |
| 478 | |
| 479 | --- |
| 480 | |
| 481 | ## `planopticon export` |
| 482 | |
| 483 | Export knowledge base content to various formats. |
| 484 | |
| 485 | ### `planopticon export markdown` |
| 486 | |
| 487 | Export knowledge base as Markdown files. |
| 488 | |
| 489 | ```bash |
| 490 | planopticon export markdown [OPTIONS] |
| 491 | ``` |
| 492 | |
| 493 | | Option | Type | Default | Description | |
| 494 | |--------|------|---------|-------------| |
| 495 | | `--input` | PATH | auto-detect | Knowledge base path | |
| 496 | | `--output` | PATH | `./export` | Output directory | |
| 497 | |
| 498 | ### `planopticon export obsidian` |
| 499 | |
| 500 | Export knowledge base as an Obsidian vault with wikilinks and graph metadata. |
| 501 | |
| 502 | ```bash |
| 503 | planopticon export obsidian [OPTIONS] |
| 504 | ``` |
| 505 | |
| 506 | | Option | Type | Default | Description | |
| 507 | |--------|------|---------|-------------| |
| 508 | | `--input` | PATH | auto-detect | Knowledge base path | |
| 509 | | `--output` | PATH | `./obsidian-vault` | Output vault directory | |
| 510 | |
| 511 | ### `planopticon export notion` |
| 512 | |
| 513 | Export knowledge base to Notion. |
| 514 | |
| 515 | ```bash |
| 516 | planopticon export notion [OPTIONS] |
| 517 | ``` |
| 518 | |
| 519 | | Option | Type | Default | Description | |
| 520 | |--------|------|---------|-------------| |
| 521 | | `--input` | PATH | auto-detect | Knowledge base path | |
| 522 | | `--parent-page` | TEXT | none | Notion parent page ID | |
| 523 | |
| 524 | ### `planopticon export exchange` |
| 525 | |
| 526 | Export knowledge base as PlanOpticon Exchange Format (JSON). |
| 527 | |
| 528 | ```bash |
| 529 | planopticon export exchange [OPTIONS] |
| 530 | ``` |
| 531 | |
| 532 | | Option | Type | Default | Description | |
| 533 | |--------|------|---------|-------------| |
| 534 | | `--input` | PATH | auto-detect | Knowledge base path | |
| 535 | | `--output` | PATH | `./exchange.json` | Output file path | |
| 536 | |
| 537 | **Examples:** |
| 538 | |
| 539 | ```bash |
| 540 | # Export to Markdown |
| 541 | planopticon export markdown --input ./kb --output ./docs |
| 542 | |
| 543 | # Export to Obsidian vault |
| 544 | planopticon export obsidian --input ./kb --output ~/Obsidian/PlanOpticon |
| 545 | |
| 546 | # Export to Notion |
| 547 | planopticon export notion --input ./kb --parent-page abc123 |
| 548 | |
| 549 | # Export as exchange format for interoperability |
| 550 | planopticon export exchange --input ./kb --output ./export.json |
| 551 | ``` |
| 552 | |
| 553 | --- |
| 554 | |
| 555 | ## `planopticon wiki` |
| 556 | |
| 557 | Generate and publish wiki documentation from your knowledge base. |
| 558 | |
| 559 | ### `planopticon wiki generate` |
| 560 | |
| 561 | Generate a static wiki site from the knowledge base. |
| 562 | |
| 563 | ```bash |
| 564 | planopticon wiki generate [OPTIONS] |
| 565 | ``` |
| 566 | |
| 567 | | Option | Type | Default | Description | |
| 568 | |--------|------|---------|-------------| |
| 569 | | `--input` | PATH | auto-detect | Knowledge base path | |
| 570 | | `--output` | PATH | `./wiki` | Output directory | |
| 571 | |
| 572 | ### `planopticon wiki push` |
| 573 | |
| 574 | Push a generated wiki to a remote target (e.g., GitHub Wiki, Confluence). |
| 575 | |
| 576 | ```bash |
| 577 | planopticon wiki push [OPTIONS] |
| 578 | ``` |
| 579 | |
| 580 | | Option | Type | Default | Description | |
| 581 | |--------|------|---------|-------------| |
| 582 | | `--input` | PATH | `./wiki` | Wiki directory to push | |
| 583 | | `--target` | TEXT | *required* | Push target (e.g., `github://org/repo`, `confluence://space`) | |
| 584 | |
| 585 | **Examples:** |
| 586 | |
| 587 | ```bash |
| 588 | # Generate a wiki from the knowledge base |
| 589 | planopticon wiki generate --input ./kb --output ./wiki |
| 590 | |
| 591 | # Push wiki to GitHub |
| 592 | planopticon wiki push --input ./wiki --target "github://ConflictHQ/project-wiki" |
| 593 | ``` |
| 594 | |
| 595 | --- |
| 596 | |
| 597 | ## `planopticon kg` |
| 598 | |
| 599 | Knowledge graph management commands. |
| 600 | |
| 601 | ### `planopticon kg convert` |
| 602 | |
| 603 | Convert a knowledge graph between formats. |
| 604 | |
| 605 | ```bash |
| 606 | planopticon kg convert [OPTIONS] |
| 607 | ``` |
| 608 | |
| 609 | | Option | Type | Default | Description | |
| 610 | |--------|------|---------|-------------| |
| 611 | | `--input` | PATH | *required* | Input knowledge graph file | |
| 612 | | `--output` | PATH | *required* | Output file path | |
| 613 | | `--format` | `json\|db\|graphml\|csv` | auto (from extension) | Target format | |
| 614 | |
| 615 | ### `planopticon kg sync` |
| 616 | |
| 617 | Synchronize two knowledge graphs (merge new data). |
| 618 | |
| 619 | ```bash |
| 620 | planopticon kg sync [OPTIONS] |
| 621 | ``` |
| 622 | |
| 623 | | Option | Type | Default | Description | |
| 624 | |--------|------|---------|-------------| |
| 625 | | `--source` | PATH | *required* | Source knowledge graph | |
| 626 | | `--target` | PATH | *required* | Target knowledge graph to merge into | |
| 627 | |
| 628 | ### `planopticon kg inspect` |
| 629 | |
| 630 | Inspect a knowledge graph and display statistics. |
| 631 | |
| 632 | ```bash |
| 633 | planopticon kg inspect [OPTIONS] [PATH] |
| 634 | ``` |
| 635 | |
| 636 | | Option | Type | Default | Description | |
| 637 | |--------|------|---------|-------------| |
| 638 | | `PATH` | PATH | auto-detect | Knowledge graph file | |
| 639 | |
| 640 | ### `planopticon kg classify` |
| 641 | |
| 642 | Classify and tag entities in a knowledge graph. |
| 643 | |
| 644 | ```bash |
| 645 | planopticon kg classify [OPTIONS] |
| 646 | ``` |
| 647 | |
| 648 | | Option | Type | Default | Description | |
| 649 | |--------|------|---------|-------------| |
| 650 | | `--db-path` | PATH | auto-detect | Knowledge graph database | |
| 651 | | `-p`, `--provider` | TEXT | `auto` | AI provider for classification | |
| 652 | |
| 653 | ### `planopticon kg from-exchange` |
| 654 | |
| 655 | Import a knowledge graph from PlanOpticon Exchange Format. |
| 656 | |
| 657 | ```bash |
| 658 | planopticon kg from-exchange [OPTIONS] INPUT |
| 659 | ``` |
| 660 | |
| 661 | | Option | Type | Default | Description | |
| 662 | |--------|------|---------|-------------| |
| 663 | | `INPUT` | PATH | *required* | Exchange format JSON file | |
| 664 | | `--output` | PATH | `./knowledge-base` | Output knowledge base directory | |
| 665 | |
| 666 | **Examples:** |
| 667 | |
| 668 | ```bash |
| 669 | # Convert JSON knowledge graph to FalkorDB format |
| 670 | planopticon kg convert --input ./kg.json --output ./kg.db |
| 671 | |
| 672 | # Merge two knowledge graphs |
| 673 | planopticon kg sync --source ./new-kg.db --target ./main-kg.db |
| 674 | |
| 675 | # Inspect a knowledge graph |
| 676 | planopticon kg inspect ./knowledge_graph.db |
| 677 | |
| 678 | # Classify entities with AI |
| 679 | planopticon kg classify --db-path ./kg.db --provider anthropic |
| 680 | |
| 681 | # Import from exchange format |
| 682 | planopticon kg from-exchange ./export.json --output ./kb |
| 683 | ``` |
| 684 |
| --- docs/getting-started/configuration.md | ||
| +++ docs/getting-started/configuration.md | ||
| @@ -1,27 +1,70 @@ | ||
| 1 | 1 | # Configuration |
| 2 | 2 | |
| 3 | 3 | ## Environment variables |
| 4 | + | |
| 5 | +### AI providers | |
| 4 | 6 | |
| 5 | 7 | | Variable | Description | |
| 6 | 8 | |----------|-------------| |
| 7 | 9 | | `OPENAI_API_KEY` | OpenAI API key | |
| 8 | 10 | | `ANTHROPIC_API_KEY` | Anthropic API key | |
| 9 | 11 | | `GEMINI_API_KEY` | Google Gemini API key | |
| 12 | +| `AZURE_OPENAI_API_KEY` | Azure OpenAI API key | | |
| 13 | +| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint URL | | |
| 14 | +| `TOGETHER_API_KEY` | Together AI API key | | |
| 15 | +| `FIREWORKS_API_KEY` | Fireworks AI API key | | |
| 16 | +| `CEREBRAS_API_KEY` | Cerebras API key | | |
| 17 | +| `XAI_API_KEY` | xAI (Grok) API key | | |
| 10 | 18 | | `OLLAMA_HOST` | Ollama server URL (default: `http://localhost:11434`) | |
| 11 | -| `GOOGLE_APPLICATION_CREDENTIALS` | Path to Google service account JSON (for Drive) | | |
| 19 | + | |
| 20 | +### Cloud services | |
| 21 | + | |
| 22 | +| Variable | Description | | |
| 23 | +|----------|-------------| | |
| 24 | +| `GOOGLE_APPLICATION_CREDENTIALS` | Path to Google service account JSON (for server-side Drive access) | | |
| 25 | +| `ZOOM_CLIENT_ID` | Zoom OAuth app client ID | | |
| 26 | +| `ZOOM_CLIENT_SECRET` | Zoom OAuth app client secret | | |
| 27 | +| `NOTION_API_KEY` | Notion integration token | | |
| 28 | +| `GITHUB_TOKEN` | GitHub personal access token | | |
| 29 | +| `MICROSOFT_CLIENT_ID` | Azure AD app client ID (for Microsoft 365) | | |
| 30 | +| `MICROSOFT_CLIENT_SECRET` | Azure AD app client secret | | |
| 31 | + | |
| 32 | +### General | |
| 33 | + | |
| 34 | +| Variable | Description | | |
| 35 | +|----------|-------------| | |
| 12 | 36 | | `CACHE_DIR` | Directory for API response caching | |
| 13 | 37 | |
| 38 | +## Authentication | |
| 39 | + | |
| 40 | +Most cloud services use OAuth via the `planopticon auth` command. Run it once per service to store credentials locally: | |
| 41 | + | |
| 42 | +```bash | |
| 43 | +planopticon auth google # Google Drive, Docs, Meet, YouTube | |
| 44 | +planopticon auth dropbox # Dropbox | |
| 45 | +planopticon auth zoom # Zoom recordings | |
| 46 | +planopticon auth notion # Notion pages | |
| 47 | +planopticon auth github # GitHub repos and wikis | |
| 48 | +planopticon auth microsoft # OneDrive, SharePoint, Teams | |
| 49 | +``` | |
| 50 | + | |
| 51 | +Credentials are stored in `~/.config/planopticon/`. Use `planopticon auth SERVICE --logout` to remove them. | |
| 52 | + | |
| 53 | +For Zoom and Microsoft 365, you also need to set the client ID and secret environment variables before running `planopticon auth`. | |
| 54 | + | |
| 14 | 55 | ## Provider routing |
| 15 | 56 | |
| 16 | -PlanOpticon auto-discovers available models and routes each task to the best option: | |
| 57 | +PlanOpticon auto-discovers available models and routes each task to the cheapest capable option: | |
| 17 | 58 | |
| 18 | 59 | | Task | Default preference | |
| 19 | 60 | |------|--------------------| |
| 20 | -| Vision (diagrams) | Gemini Flash > GPT-4o > Claude Sonnet > Ollama | | |
| 21 | -| Chat (analysis) | Claude Sonnet > GPT-4o > Gemini Flash > Ollama | | |
| 61 | +| Vision (diagrams) | Gemini Flash > GPT-4o-mini > Claude Haiku > Ollama | | |
| 62 | +| Chat (analysis) | Claude Haiku > GPT-4o-mini > Gemini Flash > Ollama | | |
| 22 | 63 | | Transcription | Local Whisper > Whisper-1 > Gemini Flash | |
| 64 | + | |
| 65 | +Default models prioritize cost efficiency. For complex or high-stakes analysis, override with more capable models using `--chat-model` or `--vision-model`. | |
| 23 | 66 | |
| 24 | 67 | If no cloud API keys are configured, PlanOpticon automatically falls back to Ollama when a local server is running. This enables fully offline operation when paired with local Whisper for transcription. |
| 25 | 68 | |
| 26 | 69 | Override with `--provider`, `--vision-model`, or `--chat-model` flags. |
| 27 | 70 | |
| 28 | 71 |
| --- docs/getting-started/configuration.md | |
| +++ docs/getting-started/configuration.md | |
| @@ -1,27 +1,70 @@ | |
| 1 | # Configuration |
| 2 | |
| 3 | ## Environment variables |
| 4 | |
| 5 | | Variable | Description | |
| 6 | |----------|-------------| |
| 7 | | `OPENAI_API_KEY` | OpenAI API key | |
| 8 | | `ANTHROPIC_API_KEY` | Anthropic API key | |
| 9 | | `GEMINI_API_KEY` | Google Gemini API key | |
| 10 | | `OLLAMA_HOST` | Ollama server URL (default: `http://localhost:11434`) | |
| 11 | | `GOOGLE_APPLICATION_CREDENTIALS` | Path to Google service account JSON (for Drive) | |
| 12 | | `CACHE_DIR` | Directory for API response caching | |
| 13 | |
| 14 | ## Provider routing |
| 15 | |
| 16 | PlanOpticon auto-discovers available models and routes each task to the best option: |
| 17 | |
| 18 | | Task | Default preference | |
| 19 | |------|--------------------| |
| 20 | | Vision (diagrams) | Gemini Flash > GPT-4o > Claude Sonnet > Ollama | |
| 21 | | Chat (analysis) | Claude Sonnet > GPT-4o > Gemini Flash > Ollama | |
| 22 | | Transcription | Local Whisper > Whisper-1 > Gemini Flash | |
| 23 | |
| 24 | If no cloud API keys are configured, PlanOpticon automatically falls back to Ollama when a local server is running. This enables fully offline operation when paired with local Whisper for transcription. |
| 25 | |
| 26 | Override with `--provider`, `--vision-model`, or `--chat-model` flags. |
| 27 | |
| 28 |
| --- docs/getting-started/configuration.md | |
| +++ docs/getting-started/configuration.md | |
| @@ -1,27 +1,70 @@ | |
| 1 | # Configuration |
| 2 | |
| 3 | ## Environment variables |
| 4 | |
| 5 | ### AI providers |
| 6 | |
| 7 | | Variable | Description | |
| 8 | |----------|-------------| |
| 9 | | `OPENAI_API_KEY` | OpenAI API key | |
| 10 | | `ANTHROPIC_API_KEY` | Anthropic API key | |
| 11 | | `GEMINI_API_KEY` | Google Gemini API key | |
| 12 | | `AZURE_OPENAI_API_KEY` | Azure OpenAI API key | |
| 13 | | `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint URL | |
| 14 | | `TOGETHER_API_KEY` | Together AI API key | |
| 15 | | `FIREWORKS_API_KEY` | Fireworks AI API key | |
| 16 | | `CEREBRAS_API_KEY` | Cerebras API key | |
| 17 | | `XAI_API_KEY` | xAI (Grok) API key | |
| 18 | | `OLLAMA_HOST` | Ollama server URL (default: `http://localhost:11434`) | |
| 19 | |
| 20 | ### Cloud services |
| 21 | |
| 22 | | Variable | Description | |
| 23 | |----------|-------------| |
| 24 | | `GOOGLE_APPLICATION_CREDENTIALS` | Path to Google service account JSON (for server-side Drive access) | |
| 25 | | `ZOOM_CLIENT_ID` | Zoom OAuth app client ID | |
| 26 | | `ZOOM_CLIENT_SECRET` | Zoom OAuth app client secret | |
| 27 | | `NOTION_API_KEY` | Notion integration token | |
| 28 | | `GITHUB_TOKEN` | GitHub personal access token | |
| 29 | | `MICROSOFT_CLIENT_ID` | Azure AD app client ID (for Microsoft 365) | |
| 30 | | `MICROSOFT_CLIENT_SECRET` | Azure AD app client secret | |
| 31 | |
| 32 | ### General |
| 33 | |
| 34 | | Variable | Description | |
| 35 | |----------|-------------| |
| 36 | | `CACHE_DIR` | Directory for API response caching | |
| 37 | |
| 38 | ## Authentication |
| 39 | |
| 40 | Most cloud services use OAuth via the `planopticon auth` command. Run it once per service to store credentials locally: |
| 41 | |
| 42 | ```bash |
| 43 | planopticon auth google # Google Drive, Docs, Meet, YouTube |
| 44 | planopticon auth dropbox # Dropbox |
| 45 | planopticon auth zoom # Zoom recordings |
| 46 | planopticon auth notion # Notion pages |
| 47 | planopticon auth github # GitHub repos and wikis |
| 48 | planopticon auth microsoft # OneDrive, SharePoint, Teams |
| 49 | ``` |
| 50 | |
| 51 | Credentials are stored in `~/.config/planopticon/`. Use `planopticon auth SERVICE --logout` to remove them. |
| 52 | |
| 53 | For Zoom and Microsoft 365, you also need to set the client ID and secret environment variables before running `planopticon auth`. |
| 54 | |
| 55 | ## Provider routing |
| 56 | |
| 57 | PlanOpticon auto-discovers available models and routes each task to the cheapest capable option: |
| 58 | |
| 59 | | Task | Default preference | |
| 60 | |------|--------------------| |
| 61 | | Vision (diagrams) | Gemini Flash > GPT-4o-mini > Claude Haiku > Ollama | |
| 62 | | Chat (analysis) | Claude Haiku > GPT-4o-mini > Gemini Flash > Ollama | |
| 63 | | Transcription | Local Whisper > Whisper-1 > Gemini Flash | |
| 64 | |
| 65 | Default models prioritize cost efficiency. For complex or high-stakes analysis, override with more capable models using `--chat-model` or `--vision-model`. |
| 66 | |
| 67 | If no cloud API keys are configured, PlanOpticon automatically falls back to Ollama when a local server is running. This enables fully offline operation when paired with local Whisper for transcription. |
| 68 | |
| 69 | Override with `--provider`, `--vision-model`, or `--chat-model` flags. |
| 70 | |
| 71 |
| --- docs/getting-started/quickstart.md | ||
| +++ docs/getting-started/quickstart.md | ||
| @@ -63,10 +63,90 @@ | ||
| 63 | 63 | Batch mode produces per-video outputs plus: |
| 64 | 64 | |
| 65 | 65 | - Merged knowledge graph across all videos |
| 66 | 66 | - Batch summary with aggregated action items |
| 67 | 67 | - Cross-referenced entities |
| 68 | + | |
| 69 | +## Ingest documents | |
| 70 | + | |
| 71 | +Build a knowledge graph from documents, notes, or any text content: | |
| 72 | + | |
| 73 | +```bash | |
| 74 | +# Ingest a single file | |
| 75 | +planopticon ingest ./meeting-notes.md --output ./kb | |
| 76 | + | |
| 77 | +# Ingest a directory recursively | |
| 78 | +planopticon ingest ./docs/ --output ./kb --recursive | |
| 79 | + | |
| 80 | +# Ingest from a URL | |
| 81 | +planopticon ingest "https://www.youtube.com/watch?v=example" --output ./kb | |
| 82 | +``` | |
| 83 | + | |
| 84 | +## Companion REPL | |
| 85 | + | |
| 86 | +Chat with your knowledge base interactively: | |
| 87 | + | |
| 88 | +```bash | |
| 89 | +# Start the companion | |
| 90 | +planopticon companion --kb ./kb | |
| 91 | + | |
| 92 | +# Use a specific provider | |
| 93 | +planopticon companion --kb ./kb --provider anthropic | |
| 94 | +``` | |
| 95 | + | |
| 96 | +The companion understands your knowledge graph and can answer questions, find connections, and summarize topics conversationally. | |
| 97 | + | |
| 98 | +## Planning agent | |
| 99 | + | |
| 100 | +Run the planning agent for adaptive, goal-directed analysis: | |
| 101 | + | |
| 102 | +```bash | |
| 103 | +# Interactive mode — the agent asks before each action | |
| 104 | +planopticon agent --kb ./kb --interactive | |
| 105 | + | |
| 106 | +# Non-interactive with export | |
| 107 | +planopticon agent --kb ./kb --export ./plan.md | |
| 108 | +``` | |
| 109 | + | |
| 110 | +## Query the knowledge graph | |
| 111 | + | |
| 112 | +Query your knowledge graph directly without an AI provider: | |
| 113 | + | |
| 114 | +```bash | |
| 115 | +# Show graph stats (entity/relationship counts) | |
| 116 | +planopticon query stats | |
| 117 | + | |
| 118 | +# List entities by type | |
| 119 | +planopticon query "entities --type technology" | |
| 120 | + | |
| 121 | +# Find neighbors of an entity | |
| 122 | +planopticon query "neighbors Alice" | |
| 123 | + | |
| 124 | +# Natural language query (requires API key) | |
| 125 | +planopticon query "What technologies were discussed?" | |
| 126 | + | |
| 127 | +# Interactive REPL | |
| 128 | +planopticon query -I | |
| 129 | +``` | |
| 130 | + | |
| 131 | +## Export | |
| 132 | + | |
| 133 | +Export your knowledge base to various formats: | |
| 134 | + | |
| 135 | +```bash | |
| 136 | +# Export to Markdown files | |
| 137 | +planopticon export markdown --input ./kb --output ./docs | |
| 138 | + | |
| 139 | +# Export to an Obsidian vault | |
| 140 | +planopticon export obsidian --input ./kb --output ~/Obsidian/PlanOpticon | |
| 141 | + | |
| 142 | +# Export to Notion | |
| 143 | +planopticon export notion --input ./kb --parent-page abc123 | |
| 144 | + | |
| 145 | +# Export as exchange format (portable JSON) | |
| 146 | +planopticon export exchange --input ./kb --output ./export.json | |
| 147 | +``` | |
| 68 | 148 | |
| 69 | 149 | ## Discover available models |
| 70 | 150 | |
| 71 | 151 | ```bash |
| 72 | 152 | planopticon list-models |
| 73 | 153 |
| --- docs/getting-started/quickstart.md | |
| +++ docs/getting-started/quickstart.md | |
| @@ -63,10 +63,90 @@ | |
| 63 | Batch mode produces per-video outputs plus: |
| 64 | |
| 65 | - Merged knowledge graph across all videos |
| 66 | - Batch summary with aggregated action items |
| 67 | - Cross-referenced entities |
| 68 | |
| 69 | ## Discover available models |
| 70 | |
| 71 | ```bash |
| 72 | planopticon list-models |
| 73 |
| --- docs/getting-started/quickstart.md | |
| +++ docs/getting-started/quickstart.md | |
| @@ -63,10 +63,90 @@ | |
| 63 | Batch mode produces per-video outputs plus: |
| 64 | |
| 65 | - Merged knowledge graph across all videos |
| 66 | - Batch summary with aggregated action items |
| 67 | - Cross-referenced entities |
| 68 | |
| 69 | ## Ingest documents |
| 70 | |
| 71 | Build a knowledge graph from documents, notes, or any text content: |
| 72 | |
| 73 | ```bash |
| 74 | # Ingest a single file |
| 75 | planopticon ingest ./meeting-notes.md --output ./kb |
| 76 | |
| 77 | # Ingest a directory recursively |
| 78 | planopticon ingest ./docs/ --output ./kb --recursive |
| 79 | |
| 80 | # Ingest from a URL |
| 81 | planopticon ingest "https://www.youtube.com/watch?v=example" --output ./kb |
| 82 | ``` |
| 83 | |
| 84 | ## Companion REPL |
| 85 | |
| 86 | Chat with your knowledge base interactively: |
| 87 | |
| 88 | ```bash |
| 89 | # Start the companion |
| 90 | planopticon companion --kb ./kb |
| 91 | |
| 92 | # Use a specific provider |
| 93 | planopticon companion --kb ./kb --provider anthropic |
| 94 | ``` |
| 95 | |
| 96 | The companion understands your knowledge graph and can answer questions, find connections, and summarize topics conversationally. |
| 97 | |
| 98 | ## Planning agent |
| 99 | |
| 100 | Run the planning agent for adaptive, goal-directed analysis: |
| 101 | |
| 102 | ```bash |
| 103 | # Interactive mode — the agent asks before each action |
| 104 | planopticon agent --kb ./kb --interactive |
| 105 | |
| 106 | # Non-interactive with export |
| 107 | planopticon agent --kb ./kb --export ./plan.md |
| 108 | ``` |
| 109 | |
| 110 | ## Query the knowledge graph |
| 111 | |
| 112 | Query your knowledge graph directly without an AI provider: |
| 113 | |
| 114 | ```bash |
| 115 | # Show graph stats (entity/relationship counts) |
| 116 | planopticon query stats |
| 117 | |
| 118 | # List entities by type |
| 119 | planopticon query "entities --type technology" |
| 120 | |
| 121 | # Find neighbors of an entity |
| 122 | planopticon query "neighbors Alice" |
| 123 | |
| 124 | # Natural language query (requires API key) |
| 125 | planopticon query "What technologies were discussed?" |
| 126 | |
| 127 | # Interactive REPL |
| 128 | planopticon query -I |
| 129 | ``` |
| 130 | |
| 131 | ## Export |
| 132 | |
| 133 | Export your knowledge base to various formats: |
| 134 | |
| 135 | ```bash |
| 136 | # Export to Markdown files |
| 137 | planopticon export markdown --input ./kb --output ./docs |
| 138 | |
| 139 | # Export to an Obsidian vault |
| 140 | planopticon export obsidian --input ./kb --output ~/Obsidian/PlanOpticon |
| 141 | |
| 142 | # Export to Notion |
| 143 | planopticon export notion --input ./kb --parent-page abc123 |
| 144 | |
| 145 | # Export as exchange format (portable JSON) |
| 146 | planopticon export exchange --input ./kb --output ./export.json |
| 147 | ``` |
| 148 | |
| 149 | ## Discover available models |
| 150 | |
| 151 | ```bash |
| 152 | planopticon list-models |
| 153 |
+174
-1
| --- docs/guide/cloud-sources.md | ||
| +++ docs/guide/cloud-sources.md | ||
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | # Cloud Sources |
| 2 | 2 | |
| 3 | -PlanOpticon can fetch videos directly from cloud storage services. | |
| 3 | +PlanOpticon connects to 20+ source platforms for fetching videos, documents, and notes. | |
| 4 | 4 | |
| 5 | 5 | ## Google Drive |
| 6 | 6 | |
| 7 | 7 | ### Service account auth |
| 8 | 8 | |
| @@ -25,10 +25,75 @@ | ||
| 25 | 25 | ### Install |
| 26 | 26 | |
| 27 | 27 | ```bash |
| 28 | 28 | pip install planopticon[gdrive] |
| 29 | 29 | ``` |
| 30 | + | |
| 31 | +## Google Workspace (gws) | |
| 32 | + | |
| 33 | +Full Google Workspace integration beyond just Drive. Access Docs, Sheets, Slides, and Meet recordings through the `gws` CLI group. | |
| 34 | + | |
| 35 | +### Setup | |
| 36 | + | |
| 37 | +```bash | |
| 38 | +planopticon auth google | |
| 39 | +``` | |
| 40 | + | |
| 41 | +A single Google OAuth session covers Drive, Docs, Sheets, Slides, and Meet. | |
| 42 | + | |
| 43 | +### Usage | |
| 44 | + | |
| 45 | +```bash | |
| 46 | +# List all Google Workspace content | |
| 47 | +planopticon gws list | |
| 48 | + | |
| 49 | +# List only Google Docs | |
| 50 | +planopticon gws list --type docs | |
| 51 | + | |
| 52 | +# List Meet recordings | |
| 53 | +planopticon gws list --type meet | |
| 54 | + | |
| 55 | +# Fetch a specific file | |
| 56 | +planopticon gws fetch abc123def --output ./downloads | |
| 57 | + | |
| 58 | +# Ingest an entire Drive folder into a knowledge base | |
| 59 | +planopticon gws ingest --folder-id abc123 --output ./kb --recursive | |
| 60 | +``` | |
| 61 | + | |
| 62 | +## Microsoft 365 (m365) | |
| 63 | + | |
| 64 | +Access OneDrive, SharePoint, Teams recordings, and Outlook content. | |
| 65 | + | |
| 66 | +### Setup | |
| 67 | + | |
| 68 | +```bash | |
| 69 | +# Set your Azure AD app credentials | |
| 70 | +export MICROSOFT_CLIENT_ID="your-client-id" | |
| 71 | +export MICROSOFT_CLIENT_SECRET="your-client-secret" | |
| 72 | + | |
| 73 | +# Authenticate | |
| 74 | +planopticon auth microsoft | |
| 75 | +``` | |
| 76 | + | |
| 77 | +### Usage | |
| 78 | + | |
| 79 | +```bash | |
| 80 | +# List all Microsoft 365 content | |
| 81 | +planopticon m365 list | |
| 82 | + | |
| 83 | +# List only Teams recordings | |
| 84 | +planopticon m365 list --type teams | |
| 85 | + | |
| 86 | +# List SharePoint files from a specific site | |
| 87 | +planopticon m365 list --type sharepoint --site "Engineering" | |
| 88 | + | |
| 89 | +# Fetch a specific file | |
| 90 | +planopticon m365 fetch item-id-123 --output ./downloads | |
| 91 | + | |
| 92 | +# Ingest SharePoint content into a knowledge base | |
| 93 | +planopticon m365 ingest --site "Engineering" --path "/Shared Documents" --output ./kb --recursive | |
| 94 | +``` | |
| 30 | 95 | |
| 31 | 96 | ## Dropbox |
| 32 | 97 | |
| 33 | 98 | ### OAuth2 auth |
| 34 | 99 | |
| @@ -40,11 +105,119 @@ | ||
| 40 | 105 | ### Install |
| 41 | 106 | |
| 42 | 107 | ```bash |
| 43 | 108 | pip install planopticon[dropbox] |
| 44 | 109 | ``` |
| 110 | + | |
| 111 | +## YouTube | |
| 112 | + | |
| 113 | +PlanOpticon can ingest YouTube videos by URL. Audio is extracted and transcribed, and any visible content (slides, diagrams) is captured from frames. | |
| 114 | + | |
| 115 | +```bash | |
| 116 | +# Ingest a YouTube video | |
| 117 | +planopticon ingest "https://www.youtube.com/watch?v=example" --output ./kb | |
| 118 | + | |
| 119 | +# Ingest a playlist | |
| 120 | +planopticon ingest "https://www.youtube.com/playlist?list=example" --output ./kb | |
| 121 | +``` | |
| 122 | + | |
| 123 | +YouTube ingestion uses `yt-dlp` under the hood. Install it separately if not already available: | |
| 124 | + | |
| 125 | +```bash | |
| 126 | +pip install yt-dlp | |
| 127 | +``` | |
| 128 | + | |
| 129 | +## Meeting recordings | |
| 130 | + | |
| 131 | +Access cloud recordings from Zoom, Microsoft Teams, and Google Meet. | |
| 132 | + | |
| 133 | +### Zoom | |
| 134 | + | |
| 135 | +```bash | |
| 136 | +# Set credentials and authenticate | |
| 137 | +export ZOOM_CLIENT_ID="your-client-id" | |
| 138 | +export ZOOM_CLIENT_SECRET="your-client-secret" | |
| 139 | +planopticon auth zoom | |
| 140 | + | |
| 141 | +# List recent Zoom recordings | |
| 142 | +planopticon recordings zoom-list | |
| 143 | + | |
| 144 | +# List recordings from a date range | |
| 145 | +planopticon recordings zoom-list --from 2026-01-01 --to 2026-02-01 | |
| 146 | +``` | |
| 147 | + | |
| 148 | +### Microsoft Teams | |
| 149 | + | |
| 150 | +```bash | |
| 151 | +# Authenticate with Microsoft (covers Teams) | |
| 152 | +planopticon auth microsoft | |
| 153 | + | |
| 154 | +# List Teams recordings | |
| 155 | +planopticon recordings teams-list | |
| 156 | + | |
| 157 | +# List recordings from a date range | |
| 158 | +planopticon recordings teams-list --from 2026-01-01 --to 2026-02-01 | |
| 159 | +``` | |
| 160 | + | |
| 161 | +### Google Meet | |
| 162 | + | |
| 163 | +```bash | |
| 164 | +# Authenticate with Google (covers Meet) | |
| 165 | +planopticon auth google | |
| 166 | + | |
| 167 | +# List Meet recordings | |
| 168 | +planopticon recordings meet-list --limit 10 | |
| 169 | +``` | |
| 170 | + | |
| 171 | +## Notes sources | |
| 172 | + | |
| 173 | +PlanOpticon can ingest notes and documents from several note-taking platforms. | |
| 174 | + | |
| 175 | +### Obsidian | |
| 176 | + | |
| 177 | +Ingest an Obsidian vault directly. PlanOpticon follows wikilinks and parses frontmatter. | |
| 178 | + | |
| 179 | +```bash | |
| 180 | +planopticon ingest ~/Obsidian/MyVault --output ./kb --recursive | |
| 181 | +``` | |
| 182 | + | |
| 183 | +### Notion | |
| 184 | + | |
| 185 | +```bash | |
| 186 | +# Set your Notion integration token | |
| 187 | +export NOTION_API_KEY="secret_..." | |
| 188 | + | |
| 189 | +# Authenticate | |
| 190 | +planopticon auth notion | |
| 191 | + | |
| 192 | +# Export knowledge base to Notion | |
| 193 | +planopticon export notion --input ./kb --parent-page abc123 | |
| 194 | +``` | |
| 195 | + | |
| 196 | +### Apple Notes | |
| 197 | + | |
| 198 | +PlanOpticon can read Apple Notes on macOS via the system AppleScript bridge. | |
| 199 | + | |
| 200 | +```bash | |
| 201 | +planopticon ingest --source apple-notes --output ./kb | |
| 202 | +``` | |
| 203 | + | |
| 204 | +### GitHub | |
| 205 | + | |
| 206 | +Ingest README files, wikis, and documentation from GitHub repositories. | |
| 207 | + | |
| 208 | +```bash | |
| 209 | +# Set your GitHub token | |
| 210 | +export GITHUB_TOKEN="ghp_..." | |
| 211 | + | |
| 212 | +# Authenticate | |
| 213 | +planopticon auth github | |
| 214 | + | |
| 215 | +# Ingest a repo's docs | |
| 216 | +planopticon ingest "github://ConflictHQ/PlanOpticon" --output ./kb | |
| 217 | +``` | |
| 45 | 218 | |
| 46 | 219 | ## All cloud sources |
| 47 | 220 | |
| 48 | 221 | ```bash |
| 49 | 222 | pip install planopticon[cloud] |
| 50 | 223 | ``` |
| 51 | 224 |
| --- docs/guide/cloud-sources.md | |
| +++ docs/guide/cloud-sources.md | |
| @@ -1,8 +1,8 @@ | |
| 1 | # Cloud Sources |
| 2 | |
| 3 | PlanOpticon can fetch videos directly from cloud storage services. |
| 4 | |
| 5 | ## Google Drive |
| 6 | |
| 7 | ### Service account auth |
| 8 | |
| @@ -25,10 +25,75 @@ | |
| 25 | ### Install |
| 26 | |
| 27 | ```bash |
| 28 | pip install planopticon[gdrive] |
| 29 | ``` |
| 30 | |
| 31 | ## Dropbox |
| 32 | |
| 33 | ### OAuth2 auth |
| 34 | |
| @@ -40,11 +105,119 @@ | |
| 40 | ### Install |
| 41 | |
| 42 | ```bash |
| 43 | pip install planopticon[dropbox] |
| 44 | ``` |
| 45 | |
| 46 | ## All cloud sources |
| 47 | |
| 48 | ```bash |
| 49 | pip install planopticon[cloud] |
| 50 | ``` |
| 51 |
| --- docs/guide/cloud-sources.md | |
| +++ docs/guide/cloud-sources.md | |
| @@ -1,8 +1,8 @@ | |
| 1 | # Cloud Sources |
| 2 | |
| 3 | PlanOpticon connects to 20+ source platforms for fetching videos, documents, and notes. |
| 4 | |
| 5 | ## Google Drive |
| 6 | |
| 7 | ### Service account auth |
| 8 | |
| @@ -25,10 +25,75 @@ | |
| 25 | ### Install |
| 26 | |
| 27 | ```bash |
| 28 | pip install planopticon[gdrive] |
| 29 | ``` |
| 30 | |
| 31 | ## Google Workspace (gws) |
| 32 | |
| 33 | Full Google Workspace integration beyond just Drive. Access Docs, Sheets, Slides, and Meet recordings through the `gws` CLI group. |
| 34 | |
| 35 | ### Setup |
| 36 | |
| 37 | ```bash |
| 38 | planopticon auth google |
| 39 | ``` |
| 40 | |
| 41 | A single Google OAuth session covers Drive, Docs, Sheets, Slides, and Meet. |
| 42 | |
| 43 | ### Usage |
| 44 | |
| 45 | ```bash |
| 46 | # List all Google Workspace content |
| 47 | planopticon gws list |
| 48 | |
| 49 | # List only Google Docs |
| 50 | planopticon gws list --type docs |
| 51 | |
| 52 | # List Meet recordings |
| 53 | planopticon gws list --type meet |
| 54 | |
| 55 | # Fetch a specific file |
| 56 | planopticon gws fetch abc123def --output ./downloads |
| 57 | |
| 58 | # Ingest an entire Drive folder into a knowledge base |
| 59 | planopticon gws ingest --folder-id abc123 --output ./kb --recursive |
| 60 | ``` |
| 61 | |
| 62 | ## Microsoft 365 (m365) |
| 63 | |
| 64 | Access OneDrive, SharePoint, Teams recordings, and Outlook content. |
| 65 | |
| 66 | ### Setup |
| 67 | |
| 68 | ```bash |
| 69 | # Set your Azure AD app credentials |
| 70 | export MICROSOFT_CLIENT_ID="your-client-id" |
| 71 | export MICROSOFT_CLIENT_SECRET="your-client-secret" |
| 72 | |
| 73 | # Authenticate |
| 74 | planopticon auth microsoft |
| 75 | ``` |
| 76 | |
| 77 | ### Usage |
| 78 | |
| 79 | ```bash |
| 80 | # List all Microsoft 365 content |
| 81 | planopticon m365 list |
| 82 | |
| 83 | # List only Teams recordings |
| 84 | planopticon m365 list --type teams |
| 85 | |
| 86 | # List SharePoint files from a specific site |
| 87 | planopticon m365 list --type sharepoint --site "Engineering" |
| 88 | |
| 89 | # Fetch a specific file |
| 90 | planopticon m365 fetch item-id-123 --output ./downloads |
| 91 | |
| 92 | # Ingest SharePoint content into a knowledge base |
| 93 | planopticon m365 ingest --site "Engineering" --path "/Shared Documents" --output ./kb --recursive |
| 94 | ``` |
| 95 | |
| 96 | ## Dropbox |
| 97 | |
| 98 | ### OAuth2 auth |
| 99 | |
| @@ -40,11 +105,119 @@ | |
| 105 | ### Install |
| 106 | |
| 107 | ```bash |
| 108 | pip install planopticon[dropbox] |
| 109 | ``` |
| 110 | |
| 111 | ## YouTube |
| 112 | |
| 113 | PlanOpticon can ingest YouTube videos by URL. Audio is extracted and transcribed, and any visible content (slides, diagrams) is captured from frames. |
| 114 | |
| 115 | ```bash |
| 116 | # Ingest a YouTube video |
| 117 | planopticon ingest "https://www.youtube.com/watch?v=example" --output ./kb |
| 118 | |
| 119 | # Ingest a playlist |
| 120 | planopticon ingest "https://www.youtube.com/playlist?list=example" --output ./kb |
| 121 | ``` |
| 122 | |
| 123 | YouTube ingestion uses `yt-dlp` under the hood. Install it separately if not already available: |
| 124 | |
| 125 | ```bash |
| 126 | pip install yt-dlp |
| 127 | ``` |
| 128 | |
| 129 | ## Meeting recordings |
| 130 | |
| 131 | Access cloud recordings from Zoom, Microsoft Teams, and Google Meet. |
| 132 | |
| 133 | ### Zoom |
| 134 | |
| 135 | ```bash |
| 136 | # Set credentials and authenticate |
| 137 | export ZOOM_CLIENT_ID="your-client-id" |
| 138 | export ZOOM_CLIENT_SECRET="your-client-secret" |
| 139 | planopticon auth zoom |
| 140 | |
| 141 | # List recent Zoom recordings |
| 142 | planopticon recordings zoom-list |
| 143 | |
| 144 | # List recordings from a date range |
| 145 | planopticon recordings zoom-list --from 2026-01-01 --to 2026-02-01 |
| 146 | ``` |
| 147 | |
| 148 | ### Microsoft Teams |
| 149 | |
| 150 | ```bash |
| 151 | # Authenticate with Microsoft (covers Teams) |
| 152 | planopticon auth microsoft |
| 153 | |
| 154 | # List Teams recordings |
| 155 | planopticon recordings teams-list |
| 156 | |
| 157 | # List recordings from a date range |
| 158 | planopticon recordings teams-list --from 2026-01-01 --to 2026-02-01 |
| 159 | ``` |
| 160 | |
| 161 | ### Google Meet |
| 162 | |
| 163 | ```bash |
| 164 | # Authenticate with Google (covers Meet) |
| 165 | planopticon auth google |
| 166 | |
| 167 | # List Meet recordings |
| 168 | planopticon recordings meet-list --limit 10 |
| 169 | ``` |
| 170 | |
| 171 | ## Notes sources |
| 172 | |
| 173 | PlanOpticon can ingest notes and documents from several note-taking platforms. |
| 174 | |
| 175 | ### Obsidian |
| 176 | |
| 177 | Ingest an Obsidian vault directly. PlanOpticon follows wikilinks and parses frontmatter. |
| 178 | |
| 179 | ```bash |
| 180 | planopticon ingest ~/Obsidian/MyVault --output ./kb --recursive |
| 181 | ``` |
| 182 | |
| 183 | ### Notion |
| 184 | |
| 185 | ```bash |
| 186 | # Set your Notion integration token |
| 187 | export NOTION_API_KEY="secret_..." |
| 188 | |
| 189 | # Authenticate |
| 190 | planopticon auth notion |
| 191 | |
| 192 | # Export knowledge base to Notion |
| 193 | planopticon export notion --input ./kb --parent-page abc123 |
| 194 | ``` |
| 195 | |
| 196 | ### Apple Notes |
| 197 | |
| 198 | PlanOpticon can read Apple Notes on macOS via the system AppleScript bridge. |
| 199 | |
| 200 | ```bash |
| 201 | planopticon ingest --source apple-notes --output ./kb |
| 202 | ``` |
| 203 | |
| 204 | ### GitHub |
| 205 | |
| 206 | Ingest README files, wikis, and documentation from GitHub repositories. |
| 207 | |
| 208 | ```bash |
| 209 | # Set your GitHub token |
| 210 | export GITHUB_TOKEN="ghp_..." |
| 211 | |
| 212 | # Authenticate |
| 213 | planopticon auth github |
| 214 | |
| 215 | # Ingest a repo's docs |
| 216 | planopticon ingest "github://ConflictHQ/PlanOpticon" --output ./kb |
| 217 | ``` |
| 218 | |
| 219 | ## All cloud sources |
| 220 | |
| 221 | ```bash |
| 222 | pip install planopticon[cloud] |
| 223 | ``` |
| 224 |
+24
-4
| --- docs/index.md | ||
| +++ docs/index.md | ||
| @@ -1,24 +1,29 @@ | ||
| 1 | 1 | # PlanOpticon |
| 2 | 2 | |
| 3 | -**AI-powered video analysis and knowledge extraction.** | |
| 3 | +**AI-powered video analysis, knowledge extraction, and planning.** | |
| 4 | 4 | |
| 5 | -PlanOpticon processes video recordings into structured knowledge — transcripts, diagrams, action items, key points, and knowledge graphs. It auto-discovers available models across OpenAI, Anthropic, and Gemini, and produces rich multi-format output. | |
| 5 | +PlanOpticon processes video recordings and documents into structured knowledge — transcripts, diagrams, action items, key points, and knowledge graphs. It connects to 20+ source platforms, auto-discovers available models across multiple AI providers, and produces rich multi-format output with an interactive companion REPL and planning agent. | |
| 6 | 6 | |
| 7 | 7 | --- |
| 8 | 8 | |
| 9 | 9 | ## Features |
| 10 | 10 | |
| 11 | -- **Multi-provider AI** — Automatically discovers and routes to the best available model across OpenAI, Anthropic, and Google Gemini | |
| 11 | +- **Multi-provider AI** — Automatically discovers and routes to the best available model across OpenAI, Anthropic, Google Gemini, and more | |
| 12 | +- **Planning agent** — Agentic analysis that adaptively adjusts depth, focus, and strategy based on content | |
| 13 | +- **Companion REPL** — Interactive chat interface for exploring your knowledge base conversationally | |
| 14 | +- **20+ source connectors** — Google Workspace, Microsoft 365, Zoom, Teams, Meet, Notion, GitHub, YouTube, Obsidian, Apple Notes, and more | |
| 15 | +- **Document export** — Export knowledge to Markdown, Obsidian, Notion, and exchange formats | |
| 16 | +- **OAuth authentication** — Built-in `planopticon auth` for Google, Dropbox, Zoom, Notion, GitHub, and Microsoft | |
| 12 | 17 | - **Smart frame extraction** — Change detection for transitions + periodic capture (every 30s) for slow-evolving content like document scrolling |
| 13 | 18 | - **People frame filtering** — OpenCV face detection removes webcam/video conference frames, keeping only shared content (slides, documents, screen shares) |
| 14 | 19 | - **Diagram extraction** — Vision model-based classification detects flowcharts, architecture diagrams, charts, and whiteboards |
| 15 | 20 | - **Knowledge graphs** — Extracts entities and relationships, builds and merges knowledge graphs across videos |
| 16 | 21 | - **Action item detection** — Finds commitments, tasks, and follow-ups with assignees and deadlines |
| 17 | 22 | - **Batch processing** — Process entire folders of videos with merged knowledge graphs and cross-referencing |
| 18 | 23 | - **Rich output** — Markdown, HTML, PDF, Mermaid diagrams, SVG/PNG renderings, JSON manifests |
| 19 | -- **Cloud sources** — Fetch videos from Google Drive and Dropbox shared folders | |
| 24 | +- **Cloud sources** — Fetch videos from Google Drive, Dropbox, and many more cloud platforms | |
| 20 | 25 | - **Checkpoint/resume** — Pipeline resumes from where it left off if interrupted — no wasted work |
| 21 | 26 | - **Screengrab fallback** — When extraction isn't perfect, captures frames with captions — something is always better than nothing |
| 22 | 27 | |
| 23 | 28 | ## Quick Start |
| 24 | 29 | |
| @@ -27,10 +32,25 @@ | ||
| 27 | 32 | pip install planopticon |
| 28 | 33 | |
| 29 | 34 | # Analyze a single video |
| 30 | 35 | planopticon analyze -i meeting.mp4 -o ./output |
| 31 | 36 | |
| 37 | +# Ingest documents and build a knowledge graph | |
| 38 | +planopticon ingest ./notes/ --output ./kb --recursive | |
| 39 | + | |
| 40 | +# Chat with your knowledge base | |
| 41 | +planopticon companion --kb ./kb | |
| 42 | + | |
| 43 | +# Run the planning agent interactively | |
| 44 | +planopticon agent --kb ./kb --interactive | |
| 45 | + | |
| 46 | +# Query the knowledge graph | |
| 47 | +planopticon query stats | |
| 48 | + | |
| 49 | +# Export to Obsidian | |
| 50 | +planopticon export obsidian --input ./kb --output ./vault | |
| 51 | + | |
| 32 | 52 | # Process a folder of videos |
| 33 | 53 | planopticon batch -i ./recordings -o ./output --title "Weekly Meetings" |
| 34 | 54 | |
| 35 | 55 | # See available AI models |
| 36 | 56 | planopticon list-models |
| 37 | 57 | |
| 38 | 58 | ADDED knowledge-base/viewer.html |
| --- docs/index.md | |
| +++ docs/index.md | |
| @@ -1,24 +1,29 @@ | |
| 1 | # PlanOpticon |
| 2 | |
| 3 | **AI-powered video analysis and knowledge extraction.** |
| 4 | |
| 5 | PlanOpticon processes video recordings into structured knowledge — transcripts, diagrams, action items, key points, and knowledge graphs. It auto-discovers available models across OpenAI, Anthropic, and Gemini, and produces rich multi-format output. |
| 6 | |
| 7 | --- |
| 8 | |
| 9 | ## Features |
| 10 | |
| 11 | - **Multi-provider AI** — Automatically discovers and routes to the best available model across OpenAI, Anthropic, and Google Gemini |
| 12 | - **Smart frame extraction** — Change detection for transitions + periodic capture (every 30s) for slow-evolving content like document scrolling |
| 13 | - **People frame filtering** — OpenCV face detection removes webcam/video conference frames, keeping only shared content (slides, documents, screen shares) |
| 14 | - **Diagram extraction** — Vision model-based classification detects flowcharts, architecture diagrams, charts, and whiteboards |
| 15 | - **Knowledge graphs** — Extracts entities and relationships, builds and merges knowledge graphs across videos |
| 16 | - **Action item detection** — Finds commitments, tasks, and follow-ups with assignees and deadlines |
| 17 | - **Batch processing** — Process entire folders of videos with merged knowledge graphs and cross-referencing |
| 18 | - **Rich output** — Markdown, HTML, PDF, Mermaid diagrams, SVG/PNG renderings, JSON manifests |
| 19 | - **Cloud sources** — Fetch videos from Google Drive and Dropbox shared folders |
| 20 | - **Checkpoint/resume** — Pipeline resumes from where it left off if interrupted — no wasted work |
| 21 | - **Screengrab fallback** — When extraction isn't perfect, captures frames with captions — something is always better than nothing |
| 22 | |
| 23 | ## Quick Start |
| 24 | |
| @@ -27,10 +32,25 @@ | |
| 27 | pip install planopticon |
| 28 | |
| 29 | # Analyze a single video |
| 30 | planopticon analyze -i meeting.mp4 -o ./output |
| 31 | |
| 32 | # Process a folder of videos |
| 33 | planopticon batch -i ./recordings -o ./output --title "Weekly Meetings" |
| 34 | |
| 35 | # See available AI models |
| 36 | planopticon list-models |
| 37 | |
| 38 | DDED knowledge-base/viewer.html |
| --- docs/index.md | |
| +++ docs/index.md | |
| @@ -1,24 +1,29 @@ | |
| 1 | # PlanOpticon |
| 2 | |
| 3 | **AI-powered video analysis, knowledge extraction, and planning.** |
| 4 | |
| 5 | PlanOpticon processes video recordings and documents into structured knowledge — transcripts, diagrams, action items, key points, and knowledge graphs. It connects to 20+ source platforms, auto-discovers available models across multiple AI providers, and produces rich multi-format output with an interactive companion REPL and planning agent. |
| 6 | |
| 7 | --- |
| 8 | |
| 9 | ## Features |
| 10 | |
| 11 | - **Multi-provider AI** — Automatically discovers and routes to the best available model across OpenAI, Anthropic, Google Gemini, and more |
| 12 | - **Planning agent** — Agentic analysis that adaptively adjusts depth, focus, and strategy based on content |
| 13 | - **Companion REPL** — Interactive chat interface for exploring your knowledge base conversationally |
| 14 | - **20+ source connectors** — Google Workspace, Microsoft 365, Zoom, Teams, Meet, Notion, GitHub, YouTube, Obsidian, Apple Notes, and more |
| 15 | - **Document export** — Export knowledge to Markdown, Obsidian, Notion, and exchange formats |
| 16 | - **OAuth authentication** — Built-in `planopticon auth` for Google, Dropbox, Zoom, Notion, GitHub, and Microsoft |
| 17 | - **Smart frame extraction** — Change detection for transitions + periodic capture (every 30s) for slow-evolving content like document scrolling |
| 18 | - **People frame filtering** — OpenCV face detection removes webcam/video conference frames, keeping only shared content (slides, documents, screen shares) |
| 19 | - **Diagram extraction** — Vision model-based classification detects flowcharts, architecture diagrams, charts, and whiteboards |
| 20 | - **Knowledge graphs** — Extracts entities and relationships, builds and merges knowledge graphs across videos |
| 21 | - **Action item detection** — Finds commitments, tasks, and follow-ups with assignees and deadlines |
| 22 | - **Batch processing** — Process entire folders of videos with merged knowledge graphs and cross-referencing |
| 23 | - **Rich output** — Markdown, HTML, PDF, Mermaid diagrams, SVG/PNG renderings, JSON manifests |
| 24 | - **Cloud sources** — Fetch videos from Google Drive, Dropbox, and many more cloud platforms |
| 25 | - **Checkpoint/resume** — Pipeline resumes from where it left off if interrupted — no wasted work |
| 26 | - **Screengrab fallback** — When extraction isn't perfect, captures frames with captions — something is always better than nothing |
| 27 | |
| 28 | ## Quick Start |
| 29 | |
| @@ -27,10 +32,25 @@ | |
| 32 | pip install planopticon |
| 33 | |
| 34 | # Analyze a single video |
| 35 | planopticon analyze -i meeting.mp4 -o ./output |
| 36 | |
| 37 | # Ingest documents and build a knowledge graph |
| 38 | planopticon ingest ./notes/ --output ./kb --recursive |
| 39 | |
| 40 | # Chat with your knowledge base |
| 41 | planopticon companion --kb ./kb |
| 42 | |
| 43 | # Run the planning agent interactively |
| 44 | planopticon agent --kb ./kb --interactive |
| 45 | |
| 46 | # Query the knowledge graph |
| 47 | planopticon query stats |
| 48 | |
| 49 | # Export to Obsidian |
| 50 | planopticon export obsidian --input ./kb --output ./vault |
| 51 | |
| 52 | # Process a folder of videos |
| 53 | planopticon batch -i ./recordings -o ./output --title "Weekly Meetings" |
| 54 | |
| 55 | # See available AI models |
| 56 | planopticon list-models |
| 57 | |
| 58 | DDED knowledge-base/viewer.html |
+406
| --- a/knowledge-base/viewer.html | ||
| +++ b/knowledge-base/viewer.html | ||
| @@ -0,0 +1,406 @@ | ||
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="en"> | |
| 3 | +<head> | |
| 4 | +<meta charset="UTF-8"> | |
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | +<title>PlanOpticon Knowledge Graph Viewer</title> | |
| 7 | +<script src="https://d3js.org/d3.v7.min.js"></script> | |
| 8 | +<style> | |
| 9 | + * { margin: 0; padding: 0; box-sizing: border-box; } | |
| 10 | + body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; overflow: hidden; height: 100vh; } | |
| 11 | + #toolbar { position: fixed; top: 0; left: 0; right: 0; z-index: 10; background: #16213e; padding: 8px 16px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid #0f3460; flex-wrap: wrap; } | |
| 12 | + #toolbar h1 { font-size: 14px; font-weight: 600; color: #e94560; white-space: nowrap; } | |
| 13 | + #search { background: #1a1a2e; border: 1px solid #0f3460; color: #e0e0e0; padding: 5px 10px; border-radius: 4px; font-size: 13px; width: 200px; } | |
| 14 | + #search::placeholder { color: #666; } | |
| 15 | + .filter-btn { background: #1a1a2e; border: 1px solid #0f3460; color: #e0e0e0; padding: 4px 10px; border-radius: 12px; font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 4px; } | |
| 16 | + .filter-btn.active { border-color: #e94560; } | |
| 17 | + .filter-btn .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } | |
| 18 | + #drop-zone { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 20; background: #16213e; border: 2px dashed #0f3460; border-radius: 12px; padding: 60px; text-align: center; } | |
| 19 | + #drop-zone.hidden { display: none; } | |
| 20 | + #drop-zone h2 { color: #e94560; margin-bottom: 12px; } | |
| 21 | + #drop-zone p { color: #888; margin-bottom: 16px; font-size: 14px; } | |
| 22 | + #file-input { display: none; } | |
| 23 | + #drop-zone label { background: #e94560; color: white; padding: 8px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; } | |
| 24 | + #drop-zone label:hover { background: #c73e54; } | |
| 25 | + #graph-container { width: 100%; height: 100vh; padding-top: 44px; } | |
| 26 | + svg { width: 100%; height: 100%; } | |
| 27 | + .node circle { stroke: #333; stroke-width: 1.5px; cursor: pointer; } | |
| 28 | + .node text { font-size: 10px; fill: #e0e0e0; pointer-events: none; text-anchor: middle; } | |
| 29 | + .link { stroke: #334; stroke-opacity: 0.6; } | |
| 30 | + .link-label { font-size: 8px; fill: #666; pointer-events: none; } | |
| 31 | + .node.highlighted circle { stroke: #e94560; stroke-width: 3px; } | |
| 32 | + .node.dimmed { opacity: 0.15; } | |
| 33 | + .link.dimmed { opacity: 0.05; } | |
| 34 | + #detail-panel { position: fixed; top: 44px; right: 0; width: 300px; height: calc(100vh - 44px); background: #16213e; border-left: 1px solid #0f3460; padding: 16px; overflow-y: auto; transform: translateX(100%); transition: transform 0.2s; z-index: 10; } | |
| 35 | + #detail-panel.open { transform: translateX(0); } | |
| 36 | + #detail-panel h3 { color: #e94560; margin-bottom: 4px; font-size: 16px; } | |
| 37 | + #detail-panel .type-badge { display: inline-block; padding: 2px 8px; border-radius: 8px; font-size: 11px; margin-bottom: 12px; } | |
| 38 | + #detail-panel .section { margin-bottom: 14px; } | |
| 39 | + #detail-panel .section h4 { font-size: 12px; color: #888; text-transform: uppercase; margin-bottom: 6px; } | |
| 40 | + #detail-panel .section p, #detail-panel .section li { font-size: 13px; line-height: 1.5; } | |
| 41 | + #detail-panel ul { list-style: none; padding: 0; } | |
| 42 | + #detail-panel ul li { padding: 3px 0; border-bottom: 1px solid #0f3460; } | |
| 43 | + #detail-panel .close-btn { position: absolute; top: 12px; right: 12px; background: none; border: none; color: #888; cursor: pointer; font-size: 18px; } | |
| 44 | + #stats { font-size: 11px; color: #666; white-space: nowrap; } | |
| 45 | + .drag-over #drop-zone { border-color: #e94560; background: #1a1a3e; } | |
| 46 | + #no-d3-msg { display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #16213e; padding: 40px; border-radius: 12px; text-align: center; border: 1px solid #e94560; z-index: 100; } | |
| 47 | + #no-d3-msg h2 { color: #e94560; margin-bottom: 8px; } | |
| 48 | +</style> | |
| 49 | +</head> | |
| 50 | +<body> | |
| 51 | +<div id="no-d3-msg"> | |
| 52 | + <h2>D3.js Required</h2> | |
| 53 | + <p>This viewer requires an internet connection on first load to fetch D3.js.</p> | |
| 54 | + <p style="color:#888; font-size:13px;">Load from: https://d3js.org/d3.v7.min.js</p> | |
| 55 | +</div> | |
| 56 | + | |
| 57 | +<div id="toolbar"> | |
| 58 | + <h1>PlanOpticon</h1> | |
| 59 | + <input type="text" id="search" placeholder="Search entities..."> | |
| 60 | + <button class="filter-btn active" data-type="person"><span class="dot" style="background:#f9d5e5"></span> Person</button> | |
| 61 | + <button class="filter-btn active" data-type="concept"><span class="dot" style="background:#eeeeee"></span> Concept</button> | |
| 62 | + <button class="filter-btn active" data-type="technology"><span class="dot" style="background:#d5e5f9"></span> Technology</button> | |
| 63 | + <button class="filter-btn active" data-type="organization"><span class="dot" style="background:#f9e5d5"></span> Organization</button> | |
| 64 | + <span id="stats"></span> | |
| 65 | +</div> | |
| 66 | + | |
| 67 | +<div id="drop-zone"> | |
| 68 | + <h2>Load Knowledge Graph</h2> | |
| 69 | + <p>Drag and drop a knowledge_graph.json file here, or click to browse.</p> | |
| 70 | + <label for="file-input">Choose File</label> | |
| 71 | + <input type="file" id="file-input" accept=".json"> | |
| 72 | +</div> | |
| 73 | + | |
| 74 | +<div id="graph-container"> | |
| 75 | + <svg id="graph-svg"></svg> | |
| 76 | +</div> | |
| 77 | + | |
| 78 | +<div id="detail-panel"> | |
| 79 | + <button class="close-btn" id="close-detail">×</button> | |
| 80 | + <h3 id="detail-name"></h3> | |
| 81 | + <div class="type-badge" id="detail-type"></div> | |
| 82 | + <div class="section" id="desc-section"> | |
| 83 | + <h4>Descriptions</h4> | |
| 84 | + <ul id="detail-descriptions"></ul> | |
| 85 | + </div> | |
| 86 | + <div class="section" id="conn-section"> | |
| 87 | + <h4>Connections</h4> | |
| 88 | + <ul id="detail-connections"></ul> | |
| 89 | + </div> | |
| 90 | +</div> | |
| 91 | + | |
| 92 | +<script> | |
| 93 | +(function() { | |
| 94 | + // Check D3 loaded | |
| 95 | + if (typeof d3 === 'undefined') { | |
| 96 | + document.getElementById('no-d3-msg').style.display = 'block'; | |
| 97 | + return; | |
| 98 | + } | |
| 99 | + | |
| 100 | + const TYPE_COLORS = { | |
| 101 | + person: '#f9d5e5', | |
| 102 | + concept: '#eeeeee', | |
| 103 | + technology: '#d5e5f9', | |
| 104 | + organization: '#f9e5d5', | |
| 105 | + time: '#e5d5f9', | |
| 106 | + diagram: '#d5f9e5' | |
| 107 | + }; | |
| 108 | + const DEFAULT_COLOR = '#cccccc'; | |
| 109 | + | |
| 110 | + let graphData = null; // { nodes: [], relationships: [] } | |
| 111 | + let simulation = null; | |
| 112 | + let nodesG, linksG, labelsG; | |
| 113 | + let activeTypes = new Set(['person', 'concept', 'technology', 'organization', 'time', 'diagram']); | |
| 114 | + | |
| 115 | + // --- File loading --- | |
| 116 | + const dropZone = document.getElementById('drop-zone'); | |
| 117 | + const fileInput = document.getElementById('file-input'); | |
| 118 | + | |
| 119 | + dropZone.addEventListener('dragover', e => { e.preventDefault(); document.body.classList.add('drag-over'); }); | |
| 120 | + dropZone.addEventListener('dragleave', () => document.body.classList.remove('drag-over')); | |
| 121 | + dropZone.addEventListener('drop', e => { | |
| 122 | + e.preventDefault(); | |
| 123 | + document.body.classList.remove('drag-over'); | |
| 124 | + const file = e.dataTransfer.files[0]; | |
| 125 | + if (file) loadFile(file); | |
| 126 | + }); | |
| 127 | + fileInput.addEventListener('change', e => { if (e.target.files[0]) loadFile(e.target.files[0]); }); | |
| 128 | + | |
| 129 | + function loadFile(file) { | |
| 130 | + const reader = new FileReader(); | |
| 131 | + reader.onload = e => { | |
| 132 | + try { | |
| 133 | + const data = JSON.parse(e.target.result); | |
| 134 | + initGraph(data); | |
| 135 | + } catch (err) { | |
| 136 | + alert('Invalid JSON file: ' + err.message); | |
| 137 | + } | |
| 138 | + }; | |
| 139 | + reader.readAsText(file); | |
| 140 | + } | |
| 141 | + | |
| 142 | + // --- Pre-embedded data support --- | |
| 143 | + if (window.__PLANOPTICON_DATA__) { | |
| 144 | + window.addEventListener('DOMContentLoaded', () => initGraph(window.__PLANOPTICON_DATA__)); | |
| 145 | + } | |
| 146 | + // Also check after script runs (if DOM already ready) | |
| 147 | + if (document.readyState !== 'loading' && window.__PLANOPTICON_DATA__) { | |
| 148 | + initGraph(window.__PLANOPTICON_DATA__); | |
| 149 | + } | |
| 150 | + | |
| 151 | + // --- Graph init --- | |
| 152 | + function initGraph(raw) { | |
| 153 | + dropZone.classList.add('hidden'); | |
| 154 | + graphData = normalize(raw); | |
| 155 | + render(); | |
| 156 | + } | |
| 157 | + | |
| 158 | + function normalize(raw) { | |
| 159 | + // Accept both { nodes: [], relationships: [] } and { entities: [], relationships: [] } | |
| 160 | + const rawNodes = raw.nodes || raw.entities || []; | |
| 161 | + const rawRels = raw.relationships || raw.edges || []; | |
| 162 | + | |
| 163 | + const nodes = rawNodes.map(n => ({ | |
| 164 | + id: n.name || n.id, | |
| 165 | + name: n.name || n.id, | |
| 166 | + type: (n.type || 'concept').toLowerCase(), | |
| 167 | + descriptions: n.descriptions || (n.description ? [n.description] : []), | |
| 168 | + occurrences: n.occurrences || [] | |
| 169 | + })); | |
| 170 | + | |
| 171 | + const nodeSet = new Set(nodes.map(n => n.id)); | |
| 172 | + | |
| 173 | + const links = rawRels | |
| 174 | + .filter(r => nodeSet.has(r.source) && nodeSet.has(r.target)) | |
| 175 | + .map(r => ({ | |
| 176 | + source: r.source, | |
| 177 | + target: r.target, | |
| 178 | + type: r.type || 'related_to' | |
| 179 | + })); | |
| 180 | + | |
| 181 | + // Compute connection counts | |
| 182 | + const connCount = {}; | |
| 183 | + links.forEach(l => { | |
| 184 | + const s = typeof l.source === 'object' ? l.source.id : l.source; | |
| 185 | + const t = typeof l.target === 'object' ? l.target.id : l.target; | |
| 186 | + connCount[s] = (connCount[s] || 0) + 1; | |
| 187 | + connCount[t] = (connCount[t] || 0) + 1; | |
| 188 | + }); | |
| 189 | + nodes.forEach(n => { n.connections = connCount[n.id] || 0; }); | |
| 190 | + | |
| 191 | + return { nodes, links }; | |
| 192 | + } | |
| 193 | + | |
| 194 | + function render() { | |
| 195 | + const svg = d3.select('#graph-svg'); | |
| 196 | + svg.selectAll('*').remove(); | |
| 197 | + | |
| 198 | + const width = window.innerWidth; | |
| 199 | + const height = window.innerHeight - 44; | |
| 200 | + | |
| 201 | + const g = svg.append('g'); | |
| 202 | + | |
| 203 | + // Zoom | |
| 204 | + const zoom = d3.zoom() | |
| 205 | + .scaleExtent([0.1, 8]) | |
| 206 | + .on('zoom', e => g.attr('transform', e.transform)); | |
| 207 | + svg.call(zoom); | |
| 208 | + | |
| 209 | + // Filter nodes/links by active types | |
| 210 | + const visibleNodes = graphData.nodes.filter(n => activeTypes.has(n.type)); | |
| 211 | + const visibleIds = new Set(visibleNodes.map(n => n.id)); | |
| 212 | + const visibleLinks = graphData.links.filter(l => { | |
| 213 | + const s = typeof l.source === 'object' ? l.source.id : l.source; | |
| 214 | + const t = typeof l.target === 'object' ? l.target.id : l.target; | |
| 215 | + return visibleIds.has(s) && visibleIds.has(t); | |
| 216 | + }); | |
| 217 | + | |
| 218 | + // Stats | |
| 219 | + document.getElementById('stats').textContent = visibleNodes.length + ' nodes, ' + visibleLinks.length + ' edges'; | |
| 220 | + | |
| 221 | + // Links | |
| 222 | + linksG = g.append('g').selectAll('line') | |
| 223 | + .data(visibleLinks) | |
| 224 | + .join('line') | |
| 225 | + .attr('class', 'link') | |
| 226 | + .attr('stroke-width', 1); | |
| 227 | + | |
| 228 | + // Link labels | |
| 229 | + labelsG = g.append('g').selectAll('text') | |
| 230 | + .data(visibleLinks) | |
| 231 | + .join('text') | |
| 232 | + .attr('class', 'link-label') | |
| 233 | + .text(d => d.type); | |
| 234 | + | |
| 235 | + // Nodes | |
| 236 | + const maxConn = Math.max(1, d3.max(visibleNodes, d => d.connections)); | |
| 237 | + const radiusScale = d3.scaleSqrt().domain([0, maxConn]).range([5, 24]); | |
| 238 | + | |
| 239 | + nodesG = g.append('g').selectAll('g') | |
| 240 | + .data(visibleNodes) | |
| 241 | + .join('g') | |
| 242 | + .attr('class', 'node') | |
| 243 | + .call(d3.drag() | |
| 244 | + .on('start', dragStart) | |
| 245 | + .on('drag', dragged) | |
| 246 | + .on('end', dragEnd)) | |
| 247 | + .on('click', (e, d) => showDetail(d)); | |
| 248 | + | |
| 249 | + nodesG.append('circle') | |
| 250 | + .attr('r', d => radiusScale(d.connections)) | |
| 251 | + .attr('fill', d => TYPE_COLORS[d.type] || DEFAULT_COLOR); | |
| 252 | + | |
| 253 | + nodesG.append('text') | |
| 254 | + .attr('dy', d => radiusScale(d.connections) + 12) | |
| 255 | + .text(d => d.name.length > 20 ? d.name.slice(0, 18) + '..' : d.name); | |
| 256 | + | |
| 257 | + // Simulation | |
| 258 | + simulation = d3.forceSimulation(visibleNodes) | |
| 259 | + .force('link', d3.forceLink(visibleLinks).id(d => d.id).distance(80)) | |
| 260 | + .force('charge', d3.forceManyBody().strength(-200)) | |
| 261 | + .force('center', d3.forceCenter(width / 2, height / 2)) | |
| 262 | + .force('collision', d3.forceCollide().radius(d => radiusScale(d.connections) + 4)) | |
| 263 | + .on('tick', () => { | |
| 264 | + linksG | |
| 265 | + .attr('x1', d => d.source.x).attr('y1', d => d.source.y) | |
| 266 | + .attr('x2', d => d.target.x).attr('y2', d => d.target.y); | |
| 267 | + labelsG | |
| 268 | + .attr('x', d => (d.source.x + d.target.x) / 2) | |
| 269 | + .attr('y', d => (d.source.y + d.target.y) / 2); | |
| 270 | + nodesG.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')'); | |
| 271 | + }); | |
| 272 | + | |
| 273 | + // Drag handlers | |
| 274 | + function dragStart(e, d) { | |
| 275 | + if (!e.active) simulation.alphaTarget(0.3).restart(); | |
| 276 | + d.fx = d.x; d.fy = d.y; | |
| 277 | + } | |
| 278 | + function dragged(e, d) { d.fx = e.x; d.fy = e.y; } | |
| 279 | + function dragEnd(e, d) { | |
| 280 | + if (!e.active) simulation.alphaTarget(0); | |
| 281 | + d.fx = null; d.fy = null; | |
| 282 | + } | |
| 283 | + | |
| 284 | + // Store zoom for centering | |
| 285 | + svg._zoom = zoom; | |
| 286 | + svg._g = g; | |
| 287 | + } | |
| 288 | + | |
| 289 | + // --- Search --- | |
| 290 | + const searchInput = document.getElementById('search'); | |
| 291 | + searchInput.addEventListener('input', () => { | |
| 292 | + const q = searchInput.value.toLowerCase().trim(); | |
| 293 | + if (!graphData || !nodesG) return; | |
| 294 | + | |
| 295 | + if (!q) { | |
| 296 | + nodesG.classed('highlighted', false).classed('dimmed', false); | |
| 297 | + linksG.classed('dimmed', false); | |
| 298 | + return; | |
| 299 | + } | |
| 300 | + | |
| 301 | + const matches = new Set(); | |
| 302 | + graphData.nodes.forEach(n => { | |
| 303 | + if (n.name.toLowerCase().includes(q)) matches.add(n.id); | |
| 304 | + }); | |
| 305 | + | |
| 306 | + // Also include direct neighbors of matches | |
| 307 | + const neighbors = new Set(matches); | |
| 308 | + graphData.links.forEach(l => { | |
| 309 | + const s = typeof l.source === 'object' ? l.source.id : l.source; | |
| 310 | + const t = typeof l.target === 'object' ? l.target.id : l.target; | |
| 311 | + if (matches.has(s)) neighbors.add(t); | |
| 312 | + if (matches.has(t)) neighbors.add(s); | |
| 313 | + }); | |
| 314 | + | |
| 315 | + nodesG.classed('highlighted', d => matches.has(d.id)); | |
| 316 | + nodesG.classed('dimmed', d => !neighbors.has(d.id)); | |
| 317 | + linksG.classed('dimmed', d => { | |
| 318 | + const s = typeof d.source === 'object' ? d.source.id : d.source; | |
| 319 | + const t = typeof d.target === 'object' ? d.target.id : d.target; | |
| 320 | + return !neighbors.has(s) || !neighbors.has(t); | |
| 321 | + }); | |
| 322 | + | |
| 323 | + // Center on first match | |
| 324 | + if (matches.size > 0) { | |
| 325 | + const first = graphData.nodes.find(n => matches.has(n.id)); | |
| 326 | + if (first && first.x != null) { | |
| 327 | + const svg = d3.select('#graph-svg'); | |
| 328 | + const width = window.innerWidth; | |
| 329 | + const height = window.innerHeight - 44; | |
| 330 | + svg.transition().duration(500).call( | |
| 331 | + svg._zoom.transform, | |
| 332 | + d3.zoomIdentity.translate(width / 2 - first.x, height / 2 - first.y) | |
| 333 | + ); | |
| 334 | + } | |
| 335 | + } | |
| 336 | + }); | |
| 337 | + | |
| 338 | + // --- Filter toggles --- | |
| 339 | + document.querySelectorAll('.filter-btn').forEach(btn => { | |
| 340 | + btn.addEventListener('click', () => { | |
| 341 | + const type = btn.dataset.type; | |
| 342 | + btn.classList.toggle('active'); | |
| 343 | + if (activeTypes.has(type)) activeTypes.delete(type); | |
| 344 | + else activeTypes.add(type); | |
| 345 | + if (graphData) render(); | |
| 346 | + }); | |
| 347 | + }); | |
| 348 | + | |
| 349 | + // --- Detail panel --- | |
| 350 | + function showDetail(node) { | |
| 351 | + const panel = document.getElementById('detail-panel'); | |
| 352 | + document.getElementById('detail-name').textContent = node.name; | |
| 353 | + const badge = document.getElementById('detail-type'); | |
| 354 | + badge.textContent = node.type; | |
| 355 | + badge.style.background = TYPE_COLORS[node.type] || DEFAULT_COLOR; | |
| 356 | + badge.style.color = '#1a1a2e'; | |
| 357 | + | |
| 358 | + const descList = document.getElementById('detail-descriptions'); | |
| 359 | + descList.innerHTML = ''; | |
| 360 | + if (node.descriptions.length === 0) { | |
| 361 | + descList.innerHTML = '<li style="color:#666">No descriptions</li>'; | |
| 362 | + } else { | |
| 363 | + node.descriptions.forEach(d => { | |
| 364 | + const li = document.createElement('li'); | |
| 365 | + li.textContent = d; | |
| 366 | + descList.appendChild(li); | |
| 367 | + }); | |
| 368 | + } | |
| 369 | + | |
| 370 | + const connList = document.getElementById('detail-connections'); | |
| 371 | + connList.innerHTML = ''; | |
| 372 | + const connections = []; | |
| 373 | + graphData.links.forEach(l => { | |
| 374 | + const s = typeof l.source === 'object' ? l.source.id : l.source; | |
| 375 | + const t = typeof l.target === 'object' ? l.target.id : l.target; | |
| 376 | + if (s === node.id) connections.push({ target: t, type: l.type, dir: '->' }); | |
| 377 | + if (t === node.id) connections.push({ target: s, type: l.type, dir: '<-' }); | |
| 378 | + }); | |
| 379 | + if (connections.length === 0) { | |
| 380 | + connList.innerHTML = '<li style="color:#666">No connections</li>'; | |
| 381 | + } else { | |
| 382 | + connections.forEach(c => { | |
| 383 | + const li = document.createElement('li'); | |
| 384 | + li.textContent = c.dir + ' ' + c.target + ' (' + c.type + ')'; | |
| 385 | + li.style.cursor = 'pointer'; | |
| 386 | + li.addEventListener('click', () => { | |
| 387 | + searchInput.value = c.target; | |
| 388 | + searchInput.dispatchEvent(new Event('input')); | |
| 389 | + }); | |
| 390 | + connList.appendChild(li); | |
| 391 | + }); | |
| 392 | + } | |
| 393 | + | |
| 394 | + panel.classList.add('open'); | |
| 395 | + } | |
| 396 | + | |
| 397 | + document.getElementById('close-detail').addEventListener('click', () => { | |
| 398 | + document.getElementById('detail-panel').classList.remove('open'); | |
| 399 | + }); | |
| 400 | + | |
| 401 | + // --- Resize --- | |
| 402 | + window.addEventListener('resize', () => { if (graphData) render(); }); | |
| 403 | +})(); | |
| 404 | +</script> | |
| 405 | +</body> | |
| 406 | +</html> |
| --- a/knowledge-base/viewer.html | |
| +++ b/knowledge-base/viewer.html | |
| @@ -0,0 +1,406 @@ | |
| --- a/knowledge-base/viewer.html | |
| +++ b/knowledge-base/viewer.html | |
| @@ -0,0 +1,406 @@ | |
| 1 | <!DOCTYPE html> |
| 2 | <html lang="en"> |
| 3 | <head> |
| 4 | <meta charset="UTF-8"> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 6 | <title>PlanOpticon Knowledge Graph Viewer</title> |
| 7 | <script src="https://d3js.org/d3.v7.min.js"></script> |
| 8 | <style> |
| 9 | * { margin: 0; padding: 0; box-sizing: border-box; } |
| 10 | body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; overflow: hidden; height: 100vh; } |
| 11 | #toolbar { position: fixed; top: 0; left: 0; right: 0; z-index: 10; background: #16213e; padding: 8px 16px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid #0f3460; flex-wrap: wrap; } |
| 12 | #toolbar h1 { font-size: 14px; font-weight: 600; color: #e94560; white-space: nowrap; } |
| 13 | #search { background: #1a1a2e; border: 1px solid #0f3460; color: #e0e0e0; padding: 5px 10px; border-radius: 4px; font-size: 13px; width: 200px; } |
| 14 | #search::placeholder { color: #666; } |
| 15 | .filter-btn { background: #1a1a2e; border: 1px solid #0f3460; color: #e0e0e0; padding: 4px 10px; border-radius: 12px; font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 4px; } |
| 16 | .filter-btn.active { border-color: #e94560; } |
| 17 | .filter-btn .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } |
| 18 | #drop-zone { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 20; background: #16213e; border: 2px dashed #0f3460; border-radius: 12px; padding: 60px; text-align: center; } |
| 19 | #drop-zone.hidden { display: none; } |
| 20 | #drop-zone h2 { color: #e94560; margin-bottom: 12px; } |
| 21 | #drop-zone p { color: #888; margin-bottom: 16px; font-size: 14px; } |
| 22 | #file-input { display: none; } |
| 23 | #drop-zone label { background: #e94560; color: white; padding: 8px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; } |
| 24 | #drop-zone label:hover { background: #c73e54; } |
| 25 | #graph-container { width: 100%; height: 100vh; padding-top: 44px; } |
| 26 | svg { width: 100%; height: 100%; } |
| 27 | .node circle { stroke: #333; stroke-width: 1.5px; cursor: pointer; } |
| 28 | .node text { font-size: 10px; fill: #e0e0e0; pointer-events: none; text-anchor: middle; } |
| 29 | .link { stroke: #334; stroke-opacity: 0.6; } |
| 30 | .link-label { font-size: 8px; fill: #666; pointer-events: none; } |
| 31 | .node.highlighted circle { stroke: #e94560; stroke-width: 3px; } |
| 32 | .node.dimmed { opacity: 0.15; } |
| 33 | .link.dimmed { opacity: 0.05; } |
| 34 | #detail-panel { position: fixed; top: 44px; right: 0; width: 300px; height: calc(100vh - 44px); background: #16213e; border-left: 1px solid #0f3460; padding: 16px; overflow-y: auto; transform: translateX(100%); transition: transform 0.2s; z-index: 10; } |
| 35 | #detail-panel.open { transform: translateX(0); } |
| 36 | #detail-panel h3 { color: #e94560; margin-bottom: 4px; font-size: 16px; } |
| 37 | #detail-panel .type-badge { display: inline-block; padding: 2px 8px; border-radius: 8px; font-size: 11px; margin-bottom: 12px; } |
| 38 | #detail-panel .section { margin-bottom: 14px; } |
| 39 | #detail-panel .section h4 { font-size: 12px; color: #888; text-transform: uppercase; margin-bottom: 6px; } |
| 40 | #detail-panel .section p, #detail-panel .section li { font-size: 13px; line-height: 1.5; } |
| 41 | #detail-panel ul { list-style: none; padding: 0; } |
| 42 | #detail-panel ul li { padding: 3px 0; border-bottom: 1px solid #0f3460; } |
| 43 | #detail-panel .close-btn { position: absolute; top: 12px; right: 12px; background: none; border: none; color: #888; cursor: pointer; font-size: 18px; } |
| 44 | #stats { font-size: 11px; color: #666; white-space: nowrap; } |
| 45 | .drag-over #drop-zone { border-color: #e94560; background: #1a1a3e; } |
| 46 | #no-d3-msg { display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #16213e; padding: 40px; border-radius: 12px; text-align: center; border: 1px solid #e94560; z-index: 100; } |
| 47 | #no-d3-msg h2 { color: #e94560; margin-bottom: 8px; } |
| 48 | </style> |
| 49 | </head> |
| 50 | <body> |
| 51 | <div id="no-d3-msg"> |
| 52 | <h2>D3.js Required</h2> |
| 53 | <p>This viewer requires an internet connection on first load to fetch D3.js.</p> |
| 54 | <p style="color:#888; font-size:13px;">Load from: https://d3js.org/d3.v7.min.js</p> |
| 55 | </div> |
| 56 | |
| 57 | <div id="toolbar"> |
| 58 | <h1>PlanOpticon</h1> |
| 59 | <input type="text" id="search" placeholder="Search entities..."> |
| 60 | <button class="filter-btn active" data-type="person"><span class="dot" style="background:#f9d5e5"></span> Person</button> |
| 61 | <button class="filter-btn active" data-type="concept"><span class="dot" style="background:#eeeeee"></span> Concept</button> |
| 62 | <button class="filter-btn active" data-type="technology"><span class="dot" style="background:#d5e5f9"></span> Technology</button> |
| 63 | <button class="filter-btn active" data-type="organization"><span class="dot" style="background:#f9e5d5"></span> Organization</button> |
| 64 | <span id="stats"></span> |
| 65 | </div> |
| 66 | |
| 67 | <div id="drop-zone"> |
| 68 | <h2>Load Knowledge Graph</h2> |
| 69 | <p>Drag and drop a knowledge_graph.json file here, or click to browse.</p> |
| 70 | <label for="file-input">Choose File</label> |
| 71 | <input type="file" id="file-input" accept=".json"> |
| 72 | </div> |
| 73 | |
| 74 | <div id="graph-container"> |
| 75 | <svg id="graph-svg"></svg> |
| 76 | </div> |
| 77 | |
| 78 | <div id="detail-panel"> |
| 79 | <button class="close-btn" id="close-detail">×</button> |
| 80 | <h3 id="detail-name"></h3> |
| 81 | <div class="type-badge" id="detail-type"></div> |
| 82 | <div class="section" id="desc-section"> |
| 83 | <h4>Descriptions</h4> |
| 84 | <ul id="detail-descriptions"></ul> |
| 85 | </div> |
| 86 | <div class="section" id="conn-section"> |
| 87 | <h4>Connections</h4> |
| 88 | <ul id="detail-connections"></ul> |
| 89 | </div> |
| 90 | </div> |
| 91 | |
| 92 | <script> |
| 93 | (function() { |
| 94 | // Check D3 loaded |
| 95 | if (typeof d3 === 'undefined') { |
| 96 | document.getElementById('no-d3-msg').style.display = 'block'; |
| 97 | return; |
| 98 | } |
| 99 | |
| 100 | const TYPE_COLORS = { |
| 101 | person: '#f9d5e5', |
| 102 | concept: '#eeeeee', |
| 103 | technology: '#d5e5f9', |
| 104 | organization: '#f9e5d5', |
| 105 | time: '#e5d5f9', |
| 106 | diagram: '#d5f9e5' |
| 107 | }; |
| 108 | const DEFAULT_COLOR = '#cccccc'; |
| 109 | |
| 110 | let graphData = null; // { nodes: [], relationships: [] } |
| 111 | let simulation = null; |
| 112 | let nodesG, linksG, labelsG; |
| 113 | let activeTypes = new Set(['person', 'concept', 'technology', 'organization', 'time', 'diagram']); |
| 114 | |
| 115 | // --- File loading --- |
| 116 | const dropZone = document.getElementById('drop-zone'); |
| 117 | const fileInput = document.getElementById('file-input'); |
| 118 | |
| 119 | dropZone.addEventListener('dragover', e => { e.preventDefault(); document.body.classList.add('drag-over'); }); |
| 120 | dropZone.addEventListener('dragleave', () => document.body.classList.remove('drag-over')); |
| 121 | dropZone.addEventListener('drop', e => { |
| 122 | e.preventDefault(); |
| 123 | document.body.classList.remove('drag-over'); |
| 124 | const file = e.dataTransfer.files[0]; |
| 125 | if (file) loadFile(file); |
| 126 | }); |
| 127 | fileInput.addEventListener('change', e => { if (e.target.files[0]) loadFile(e.target.files[0]); }); |
| 128 | |
| 129 | function loadFile(file) { |
| 130 | const reader = new FileReader(); |
| 131 | reader.onload = e => { |
| 132 | try { |
| 133 | const data = JSON.parse(e.target.result); |
| 134 | initGraph(data); |
| 135 | } catch (err) { |
| 136 | alert('Invalid JSON file: ' + err.message); |
| 137 | } |
| 138 | }; |
| 139 | reader.readAsText(file); |
| 140 | } |
| 141 | |
| 142 | // --- Pre-embedded data support --- |
| 143 | if (window.__PLANOPTICON_DATA__) { |
| 144 | window.addEventListener('DOMContentLoaded', () => initGraph(window.__PLANOPTICON_DATA__)); |
| 145 | } |
| 146 | // Also check after script runs (if DOM already ready) |
| 147 | if (document.readyState !== 'loading' && window.__PLANOPTICON_DATA__) { |
| 148 | initGraph(window.__PLANOPTICON_DATA__); |
| 149 | } |
| 150 | |
| 151 | // --- Graph init --- |
| 152 | function initGraph(raw) { |
| 153 | dropZone.classList.add('hidden'); |
| 154 | graphData = normalize(raw); |
| 155 | render(); |
| 156 | } |
| 157 | |
| 158 | function normalize(raw) { |
| 159 | // Accept both { nodes: [], relationships: [] } and { entities: [], relationships: [] } |
| 160 | const rawNodes = raw.nodes || raw.entities || []; |
| 161 | const rawRels = raw.relationships || raw.edges || []; |
| 162 | |
| 163 | const nodes = rawNodes.map(n => ({ |
| 164 | id: n.name || n.id, |
| 165 | name: n.name || n.id, |
| 166 | type: (n.type || 'concept').toLowerCase(), |
| 167 | descriptions: n.descriptions || (n.description ? [n.description] : []), |
| 168 | occurrences: n.occurrences || [] |
| 169 | })); |
| 170 | |
| 171 | const nodeSet = new Set(nodes.map(n => n.id)); |
| 172 | |
| 173 | const links = rawRels |
| 174 | .filter(r => nodeSet.has(r.source) && nodeSet.has(r.target)) |
| 175 | .map(r => ({ |
| 176 | source: r.source, |
| 177 | target: r.target, |
| 178 | type: r.type || 'related_to' |
| 179 | })); |
| 180 | |
| 181 | // Compute connection counts |
| 182 | const connCount = {}; |
| 183 | links.forEach(l => { |
| 184 | const s = typeof l.source === 'object' ? l.source.id : l.source; |
| 185 | const t = typeof l.target === 'object' ? l.target.id : l.target; |
| 186 | connCount[s] = (connCount[s] || 0) + 1; |
| 187 | connCount[t] = (connCount[t] || 0) + 1; |
| 188 | }); |
| 189 | nodes.forEach(n => { n.connections = connCount[n.id] || 0; }); |
| 190 | |
| 191 | return { nodes, links }; |
| 192 | } |
| 193 | |
| 194 | function render() { |
| 195 | const svg = d3.select('#graph-svg'); |
| 196 | svg.selectAll('*').remove(); |
| 197 | |
| 198 | const width = window.innerWidth; |
| 199 | const height = window.innerHeight - 44; |
| 200 | |
| 201 | const g = svg.append('g'); |
| 202 | |
| 203 | // Zoom |
| 204 | const zoom = d3.zoom() |
| 205 | .scaleExtent([0.1, 8]) |
| 206 | .on('zoom', e => g.attr('transform', e.transform)); |
| 207 | svg.call(zoom); |
| 208 | |
| 209 | // Filter nodes/links by active types |
| 210 | const visibleNodes = graphData.nodes.filter(n => activeTypes.has(n.type)); |
| 211 | const visibleIds = new Set(visibleNodes.map(n => n.id)); |
| 212 | const visibleLinks = graphData.links.filter(l => { |
| 213 | const s = typeof l.source === 'object' ? l.source.id : l.source; |
| 214 | const t = typeof l.target === 'object' ? l.target.id : l.target; |
| 215 | return visibleIds.has(s) && visibleIds.has(t); |
| 216 | }); |
| 217 | |
| 218 | // Stats |
| 219 | document.getElementById('stats').textContent = visibleNodes.length + ' nodes, ' + visibleLinks.length + ' edges'; |
| 220 | |
| 221 | // Links |
| 222 | linksG = g.append('g').selectAll('line') |
| 223 | .data(visibleLinks) |
| 224 | .join('line') |
| 225 | .attr('class', 'link') |
| 226 | .attr('stroke-width', 1); |
| 227 | |
| 228 | // Link labels |
| 229 | labelsG = g.append('g').selectAll('text') |
| 230 | .data(visibleLinks) |
| 231 | .join('text') |
| 232 | .attr('class', 'link-label') |
| 233 | .text(d => d.type); |
| 234 | |
| 235 | // Nodes |
| 236 | const maxConn = Math.max(1, d3.max(visibleNodes, d => d.connections)); |
| 237 | const radiusScale = d3.scaleSqrt().domain([0, maxConn]).range([5, 24]); |
| 238 | |
| 239 | nodesG = g.append('g').selectAll('g') |
| 240 | .data(visibleNodes) |
| 241 | .join('g') |
| 242 | .attr('class', 'node') |
| 243 | .call(d3.drag() |
| 244 | .on('start', dragStart) |
| 245 | .on('drag', dragged) |
| 246 | .on('end', dragEnd)) |
| 247 | .on('click', (e, d) => showDetail(d)); |
| 248 | |
| 249 | nodesG.append('circle') |
| 250 | .attr('r', d => radiusScale(d.connections)) |
| 251 | .attr('fill', d => TYPE_COLORS[d.type] || DEFAULT_COLOR); |
| 252 | |
| 253 | nodesG.append('text') |
| 254 | .attr('dy', d => radiusScale(d.connections) + 12) |
| 255 | .text(d => d.name.length > 20 ? d.name.slice(0, 18) + '..' : d.name); |
| 256 | |
| 257 | // Simulation |
| 258 | simulation = d3.forceSimulation(visibleNodes) |
| 259 | .force('link', d3.forceLink(visibleLinks).id(d => d.id).distance(80)) |
| 260 | .force('charge', d3.forceManyBody().strength(-200)) |
| 261 | .force('center', d3.forceCenter(width / 2, height / 2)) |
| 262 | .force('collision', d3.forceCollide().radius(d => radiusScale(d.connections) + 4)) |
| 263 | .on('tick', () => { |
| 264 | linksG |
| 265 | .attr('x1', d => d.source.x).attr('y1', d => d.source.y) |
| 266 | .attr('x2', d => d.target.x).attr('y2', d => d.target.y); |
| 267 | labelsG |
| 268 | .attr('x', d => (d.source.x + d.target.x) / 2) |
| 269 | .attr('y', d => (d.source.y + d.target.y) / 2); |
| 270 | nodesG.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')'); |
| 271 | }); |
| 272 | |
| 273 | // Drag handlers |
| 274 | function dragStart(e, d) { |
| 275 | if (!e.active) simulation.alphaTarget(0.3).restart(); |
| 276 | d.fx = d.x; d.fy = d.y; |
| 277 | } |
| 278 | function dragged(e, d) { d.fx = e.x; d.fy = e.y; } |
| 279 | function dragEnd(e, d) { |
| 280 | if (!e.active) simulation.alphaTarget(0); |
| 281 | d.fx = null; d.fy = null; |
| 282 | } |
| 283 | |
| 284 | // Store zoom for centering |
| 285 | svg._zoom = zoom; |
| 286 | svg._g = g; |
| 287 | } |
| 288 | |
| 289 | // --- Search --- |
| 290 | const searchInput = document.getElementById('search'); |
| 291 | searchInput.addEventListener('input', () => { |
| 292 | const q = searchInput.value.toLowerCase().trim(); |
| 293 | if (!graphData || !nodesG) return; |
| 294 | |
| 295 | if (!q) { |
| 296 | nodesG.classed('highlighted', false).classed('dimmed', false); |
| 297 | linksG.classed('dimmed', false); |
| 298 | return; |
| 299 | } |
| 300 | |
| 301 | const matches = new Set(); |
| 302 | graphData.nodes.forEach(n => { |
| 303 | if (n.name.toLowerCase().includes(q)) matches.add(n.id); |
| 304 | }); |
| 305 | |
| 306 | // Also include direct neighbors of matches |
| 307 | const neighbors = new Set(matches); |
| 308 | graphData.links.forEach(l => { |
| 309 | const s = typeof l.source === 'object' ? l.source.id : l.source; |
| 310 | const t = typeof l.target === 'object' ? l.target.id : l.target; |
| 311 | if (matches.has(s)) neighbors.add(t); |
| 312 | if (matches.has(t)) neighbors.add(s); |
| 313 | }); |
| 314 | |
| 315 | nodesG.classed('highlighted', d => matches.has(d.id)); |
| 316 | nodesG.classed('dimmed', d => !neighbors.has(d.id)); |
| 317 | linksG.classed('dimmed', d => { |
| 318 | const s = typeof d.source === 'object' ? d.source.id : d.source; |
| 319 | const t = typeof d.target === 'object' ? d.target.id : d.target; |
| 320 | return !neighbors.has(s) || !neighbors.has(t); |
| 321 | }); |
| 322 | |
| 323 | // Center on first match |
| 324 | if (matches.size > 0) { |
| 325 | const first = graphData.nodes.find(n => matches.has(n.id)); |
| 326 | if (first && first.x != null) { |
| 327 | const svg = d3.select('#graph-svg'); |
| 328 | const width = window.innerWidth; |
| 329 | const height = window.innerHeight - 44; |
| 330 | svg.transition().duration(500).call( |
| 331 | svg._zoom.transform, |
| 332 | d3.zoomIdentity.translate(width / 2 - first.x, height / 2 - first.y) |
| 333 | ); |
| 334 | } |
| 335 | } |
| 336 | }); |
| 337 | |
| 338 | // --- Filter toggles --- |
| 339 | document.querySelectorAll('.filter-btn').forEach(btn => { |
| 340 | btn.addEventListener('click', () => { |
| 341 | const type = btn.dataset.type; |
| 342 | btn.classList.toggle('active'); |
| 343 | if (activeTypes.has(type)) activeTypes.delete(type); |
| 344 | else activeTypes.add(type); |
| 345 | if (graphData) render(); |
| 346 | }); |
| 347 | }); |
| 348 | |
| 349 | // --- Detail panel --- |
| 350 | function showDetail(node) { |
| 351 | const panel = document.getElementById('detail-panel'); |
| 352 | document.getElementById('detail-name').textContent = node.name; |
| 353 | const badge = document.getElementById('detail-type'); |
| 354 | badge.textContent = node.type; |
| 355 | badge.style.background = TYPE_COLORS[node.type] || DEFAULT_COLOR; |
| 356 | badge.style.color = '#1a1a2e'; |
| 357 | |
| 358 | const descList = document.getElementById('detail-descriptions'); |
| 359 | descList.innerHTML = ''; |
| 360 | if (node.descriptions.length === 0) { |
| 361 | descList.innerHTML = '<li style="color:#666">No descriptions</li>'; |
| 362 | } else { |
| 363 | node.descriptions.forEach(d => { |
| 364 | const li = document.createElement('li'); |
| 365 | li.textContent = d; |
| 366 | descList.appendChild(li); |
| 367 | }); |
| 368 | } |
| 369 | |
| 370 | const connList = document.getElementById('detail-connections'); |
| 371 | connList.innerHTML = ''; |
| 372 | const connections = []; |
| 373 | graphData.links.forEach(l => { |
| 374 | const s = typeof l.source === 'object' ? l.source.id : l.source; |
| 375 | const t = typeof l.target === 'object' ? l.target.id : l.target; |
| 376 | if (s === node.id) connections.push({ target: t, type: l.type, dir: '->' }); |
| 377 | if (t === node.id) connections.push({ target: s, type: l.type, dir: '<-' }); |
| 378 | }); |
| 379 | if (connections.length === 0) { |
| 380 | connList.innerHTML = '<li style="color:#666">No connections</li>'; |
| 381 | } else { |
| 382 | connections.forEach(c => { |
| 383 | const li = document.createElement('li'); |
| 384 | li.textContent = c.dir + ' ' + c.target + ' (' + c.type + ')'; |
| 385 | li.style.cursor = 'pointer'; |
| 386 | li.addEventListener('click', () => { |
| 387 | searchInput.value = c.target; |
| 388 | searchInput.dispatchEvent(new Event('input')); |
| 389 | }); |
| 390 | connList.appendChild(li); |
| 391 | }); |
| 392 | } |
| 393 | |
| 394 | panel.classList.add('open'); |
| 395 | } |
| 396 | |
| 397 | document.getElementById('close-detail').addEventListener('click', () => { |
| 398 | document.getElementById('detail-panel').classList.remove('open'); |
| 399 | }); |
| 400 | |
| 401 | // --- Resize --- |
| 402 | window.addEventListener('resize', () => { if (graphData) render(); }); |
| 403 | })(); |
| 404 | </script> |
| 405 | </body> |
| 406 | </html> |
+27
-3
| --- pyproject.toml | ||
| +++ pyproject.toml | ||
| @@ -2,11 +2,11 @@ | ||
| 2 | 2 | requires = ["setuptools>=69.0", "wheel"] |
| 3 | 3 | build-backend = "setuptools.build_meta" |
| 4 | 4 | |
| 5 | 5 | [project] |
| 6 | 6 | name = "planopticon" |
| 7 | -version = "0.3.0" | |
| 7 | +version = "0.4.0" | |
| 8 | 8 | description = "AI-powered video analysis and knowledge extraction tool" |
| 9 | 9 | readme = "README.md" |
| 10 | 10 | license = "MIT" |
| 11 | 11 | requires-python = ">=3.10" |
| 12 | 12 | authors = [ |
| @@ -50,18 +50,41 @@ | ||
| 50 | 50 | "requests>=2.31.0", |
| 51 | 51 | "tenacity>=8.2.0", |
| 52 | 52 | ] |
| 53 | 53 | |
| 54 | 54 | [project.optional-dependencies] |
| 55 | -pdf = ["weasyprint>=60.0"] | |
| 55 | +pdf = ["pymupdf>=1.24.0"] | |
| 56 | 56 | gpu = ["torch>=2.0.0", "torchvision>=0.15.0"] |
| 57 | 57 | gdrive = ["google-auth>=2.0.0", "google-auth-oauthlib>=1.0.0", "google-api-python-client>=2.0.0"] |
| 58 | 58 | dropbox = ["dropbox>=12.0.0"] |
| 59 | -graph = ["falkordblite>=0.4.0", "redis>=4.5"] | |
| 59 | +azure = ["openai>=1.0.0"] | |
| 60 | +together = ["openai>=1.0.0"] | |
| 61 | +fireworks = ["openai>=1.0.0"] | |
| 62 | +cerebras = ["openai>=1.0.0"] | |
| 63 | +xai = ["openai>=1.0.0"] | |
| 64 | +bedrock = ["boto3>=1.28"] | |
| 65 | +vertex = ["google-cloud-aiplatform>=1.38"] | |
| 66 | +mistral = ["mistralai>=1.0"] | |
| 67 | +cohere = ["cohere>=5.0"] | |
| 68 | +ai21 = ["ai21>=3.0"] | |
| 69 | +huggingface = ["huggingface_hub>=0.20"] | |
| 70 | +qianfan = ["qianfan>=0.4"] | |
| 71 | +litellm = ["litellm>=1.0"] | |
| 72 | +youtube = ["yt-dlp>=2023.0"] | |
| 73 | +s3 = ["boto3>=1.28"] | |
| 74 | +web = ["beautifulsoup4>=4.12"] | |
| 75 | +rss = ["feedparser>=6.0"] | |
| 76 | +graph = [] | |
| 60 | 77 | cloud = [ |
| 61 | 78 | "planopticon[gdrive]", |
| 62 | 79 | "planopticon[dropbox]", |
| 80 | + "planopticon[s3]", | |
| 81 | +] | |
| 82 | +sources = [ | |
| 83 | + "planopticon[youtube]", | |
| 84 | + "planopticon[web]", | |
| 85 | + "planopticon[rss]", | |
| 63 | 86 | ] |
| 64 | 87 | dev = [ |
| 65 | 88 | "pytest>=7.3.0", |
| 66 | 89 | "pytest-cov>=4.1.0", |
| 67 | 90 | "black>=23.3.0", |
| @@ -103,10 +126,11 @@ | ||
| 103 | 126 | [tool.ruff.lint] |
| 104 | 127 | select = ["E", "F", "W", "I"] |
| 105 | 128 | |
| 106 | 129 | [tool.ruff.lint.per-file-ignores] |
| 107 | 130 | "video_processor/utils/prompt_templates.py" = ["E501"] |
| 131 | +"video_processor/api/openapi_spec.py" = ["E501"] | |
| 108 | 132 | |
| 109 | 133 | [tool.mypy] |
| 110 | 134 | python_version = "3.10" |
| 111 | 135 | warn_return_any = true |
| 112 | 136 | warn_unused_configs = true |
| 113 | 137 |
| --- pyproject.toml | |
| +++ pyproject.toml | |
| @@ -2,11 +2,11 @@ | |
| 2 | requires = ["setuptools>=69.0", "wheel"] |
| 3 | build-backend = "setuptools.build_meta" |
| 4 | |
| 5 | [project] |
| 6 | name = "planopticon" |
| 7 | version = "0.3.0" |
| 8 | description = "AI-powered video analysis and knowledge extraction tool" |
| 9 | readme = "README.md" |
| 10 | license = "MIT" |
| 11 | requires-python = ">=3.10" |
| 12 | authors = [ |
| @@ -50,18 +50,41 @@ | |
| 50 | "requests>=2.31.0", |
| 51 | "tenacity>=8.2.0", |
| 52 | ] |
| 53 | |
| 54 | [project.optional-dependencies] |
| 55 | pdf = ["weasyprint>=60.0"] |
| 56 | gpu = ["torch>=2.0.0", "torchvision>=0.15.0"] |
| 57 | gdrive = ["google-auth>=2.0.0", "google-auth-oauthlib>=1.0.0", "google-api-python-client>=2.0.0"] |
| 58 | dropbox = ["dropbox>=12.0.0"] |
| 59 | graph = ["falkordblite>=0.4.0", "redis>=4.5"] |
| 60 | cloud = [ |
| 61 | "planopticon[gdrive]", |
| 62 | "planopticon[dropbox]", |
| 63 | ] |
| 64 | dev = [ |
| 65 | "pytest>=7.3.0", |
| 66 | "pytest-cov>=4.1.0", |
| 67 | "black>=23.3.0", |
| @@ -103,10 +126,11 @@ | |
| 103 | [tool.ruff.lint] |
| 104 | select = ["E", "F", "W", "I"] |
| 105 | |
| 106 | [tool.ruff.lint.per-file-ignores] |
| 107 | "video_processor/utils/prompt_templates.py" = ["E501"] |
| 108 | |
| 109 | [tool.mypy] |
| 110 | python_version = "3.10" |
| 111 | warn_return_any = true |
| 112 | warn_unused_configs = true |
| 113 |
| --- pyproject.toml | |
| +++ pyproject.toml | |
| @@ -2,11 +2,11 @@ | |
| 2 | requires = ["setuptools>=69.0", "wheel"] |
| 3 | build-backend = "setuptools.build_meta" |
| 4 | |
| 5 | [project] |
| 6 | name = "planopticon" |
| 7 | version = "0.4.0" |
| 8 | description = "AI-powered video analysis and knowledge extraction tool" |
| 9 | readme = "README.md" |
| 10 | license = "MIT" |
| 11 | requires-python = ">=3.10" |
| 12 | authors = [ |
| @@ -50,18 +50,41 @@ | |
| 50 | "requests>=2.31.0", |
| 51 | "tenacity>=8.2.0", |
| 52 | ] |
| 53 | |
| 54 | [project.optional-dependencies] |
| 55 | pdf = ["pymupdf>=1.24.0"] |
| 56 | gpu = ["torch>=2.0.0", "torchvision>=0.15.0"] |
| 57 | gdrive = ["google-auth>=2.0.0", "google-auth-oauthlib>=1.0.0", "google-api-python-client>=2.0.0"] |
| 58 | dropbox = ["dropbox>=12.0.0"] |
| 59 | azure = ["openai>=1.0.0"] |
| 60 | together = ["openai>=1.0.0"] |
| 61 | fireworks = ["openai>=1.0.0"] |
| 62 | cerebras = ["openai>=1.0.0"] |
| 63 | xai = ["openai>=1.0.0"] |
| 64 | bedrock = ["boto3>=1.28"] |
| 65 | vertex = ["google-cloud-aiplatform>=1.38"] |
| 66 | mistral = ["mistralai>=1.0"] |
| 67 | cohere = ["cohere>=5.0"] |
| 68 | ai21 = ["ai21>=3.0"] |
| 69 | huggingface = ["huggingface_hub>=0.20"] |
| 70 | qianfan = ["qianfan>=0.4"] |
| 71 | litellm = ["litellm>=1.0"] |
| 72 | youtube = ["yt-dlp>=2023.0"] |
| 73 | s3 = ["boto3>=1.28"] |
| 74 | web = ["beautifulsoup4>=4.12"] |
| 75 | rss = ["feedparser>=6.0"] |
| 76 | graph = [] |
| 77 | cloud = [ |
| 78 | "planopticon[gdrive]", |
| 79 | "planopticon[dropbox]", |
| 80 | "planopticon[s3]", |
| 81 | ] |
| 82 | sources = [ |
| 83 | "planopticon[youtube]", |
| 84 | "planopticon[web]", |
| 85 | "planopticon[rss]", |
| 86 | ] |
| 87 | dev = [ |
| 88 | "pytest>=7.3.0", |
| 89 | "pytest-cov>=4.1.0", |
| 90 | "black>=23.3.0", |
| @@ -103,10 +126,11 @@ | |
| 126 | [tool.ruff.lint] |
| 127 | select = ["E", "F", "W", "I"] |
| 128 | |
| 129 | [tool.ruff.lint.per-file-ignores] |
| 130 | "video_processor/utils/prompt_templates.py" = ["E501"] |
| 131 | "video_processor/api/openapi_spec.py" = ["E501"] |
| 132 | |
| 133 | [tool.mypy] |
| 134 | python_version = "3.10" |
| 135 | warn_return_any = true |
| 136 | warn_unused_configs = true |
| 137 |
+413
-11
| --- tests/test_agent.py | ||
| +++ tests/test_agent.py | ||
| @@ -1,15 +1,392 @@ | ||
| 1 | -"""Tests for the agentic processing orchestrator.""" | |
| 1 | +"""Tests for the planning agent, skill registry, KB context, and agent loop.""" | |
| 2 | 2 | |
| 3 | 3 | import json |
| 4 | -from unittest.mock import MagicMock | |
| 4 | +from pathlib import Path | |
| 5 | +from unittest.mock import MagicMock, patch | |
| 6 | + | |
| 7 | +import pytest | |
| 8 | + | |
| 9 | +from video_processor.agent.skills.base import ( | |
| 10 | + AgentContext, | |
| 11 | + Artifact, | |
| 12 | + Skill, | |
| 13 | + _skills, | |
| 14 | + get_skill, | |
| 15 | + list_skills, | |
| 16 | + register_skill, | |
| 17 | +) | |
| 18 | + | |
| 19 | +# --------------------------------------------------------------------------- | |
| 20 | +# Fixtures | |
| 21 | +# --------------------------------------------------------------------------- | |
| 22 | + | |
| 23 | + | |
| 24 | +@pytest.fixture(autouse=True) | |
| 25 | +def _clean_skill_registry(): | |
| 26 | + """Save and restore the global skill registry between tests.""" | |
| 27 | + original = dict(_skills) | |
| 28 | + yield | |
| 29 | + _skills.clear() | |
| 30 | + _skills.update(original) | |
| 31 | + | |
| 32 | + | |
| 33 | +class _DummySkill(Skill): | |
| 34 | + name = "dummy_test_skill" | |
| 35 | + description = "A dummy skill for testing" | |
| 36 | + | |
| 37 | + def execute(self, context: AgentContext, **kwargs) -> Artifact: | |
| 38 | + return Artifact( | |
| 39 | + name="dummy artifact", | |
| 40 | + content="dummy content", | |
| 41 | + artifact_type="document", | |
| 42 | + ) | |
| 43 | + | |
| 44 | + | |
| 45 | +class _NoLLMSkill(Skill): | |
| 46 | + """Skill that doesn't require provider_manager.""" | |
| 47 | + | |
| 48 | + name = "nollm_skill" | |
| 49 | + description = "Works without LLM" | |
| 50 | + | |
| 51 | + def execute(self, context: AgentContext, **kwargs) -> Artifact: | |
| 52 | + return Artifact( | |
| 53 | + name="nollm artifact", | |
| 54 | + content="generated", | |
| 55 | + artifact_type="document", | |
| 56 | + ) | |
| 57 | + | |
| 58 | + def can_execute(self, context: AgentContext) -> bool: | |
| 59 | + return context.knowledge_graph is not None | |
| 60 | + | |
| 61 | + | |
| 62 | +# --------------------------------------------------------------------------- | |
| 63 | +# Skill registry | |
| 64 | +# --------------------------------------------------------------------------- | |
| 65 | + | |
| 66 | + | |
| 67 | +class TestSkillRegistry: | |
| 68 | + def test_register_and_get(self): | |
| 69 | + skill = _DummySkill() | |
| 70 | + register_skill(skill) | |
| 71 | + assert get_skill("dummy_test_skill") is skill | |
| 72 | + | |
| 73 | + def test_get_unknown_returns_none(self): | |
| 74 | + assert get_skill("no_such_skill_xyz") is None | |
| 75 | + | |
| 76 | + def test_list_skills(self): | |
| 77 | + s1 = _DummySkill() | |
| 78 | + register_skill(s1) | |
| 79 | + skills = list_skills() | |
| 80 | + assert any(s.name == "dummy_test_skill" for s in skills) | |
| 81 | + | |
| 82 | + def test_list_skills_empty(self): | |
| 83 | + _skills.clear() | |
| 84 | + assert list_skills() == [] | |
| 85 | + | |
| 86 | + | |
| 87 | +# --------------------------------------------------------------------------- | |
| 88 | +# AgentContext dataclass | |
| 89 | +# --------------------------------------------------------------------------- | |
| 90 | + | |
| 91 | + | |
| 92 | +class TestAgentContext: | |
| 93 | + def test_defaults(self): | |
| 94 | + ctx = AgentContext() | |
| 95 | + assert ctx.knowledge_graph is None | |
| 96 | + assert ctx.query_engine is None | |
| 97 | + assert ctx.provider_manager is None | |
| 98 | + assert ctx.planning_entities == [] | |
| 99 | + assert ctx.user_requirements == {} | |
| 100 | + assert ctx.conversation_history == [] | |
| 101 | + assert ctx.artifacts == [] | |
| 102 | + assert ctx.config == {} | |
| 103 | + | |
| 104 | + def test_with_values(self): | |
| 105 | + mock_kg = MagicMock() | |
| 106 | + mock_qe = MagicMock() | |
| 107 | + mock_pm = MagicMock() | |
| 108 | + ctx = AgentContext( | |
| 109 | + knowledge_graph=mock_kg, | |
| 110 | + query_engine=mock_qe, | |
| 111 | + provider_manager=mock_pm, | |
| 112 | + config={"key": "value"}, | |
| 113 | + ) | |
| 114 | + assert ctx.knowledge_graph is mock_kg | |
| 115 | + assert ctx.config == {"key": "value"} | |
| 116 | + | |
| 117 | + def test_conversation_history_is_mutable(self): | |
| 118 | + ctx = AgentContext() | |
| 119 | + ctx.conversation_history.append({"role": "user", "content": "hello"}) | |
| 120 | + assert len(ctx.conversation_history) == 1 | |
| 121 | + | |
| 122 | + | |
| 123 | +# --------------------------------------------------------------------------- | |
| 124 | +# Artifact dataclass | |
| 125 | +# --------------------------------------------------------------------------- | |
| 126 | + | |
| 127 | + | |
| 128 | +class TestArtifact: | |
| 129 | + def test_basic(self): | |
| 130 | + a = Artifact(name="Plan", content="# Plan\n...", artifact_type="project_plan") | |
| 131 | + assert a.name == "Plan" | |
| 132 | + assert a.format == "markdown" # default | |
| 133 | + assert a.metadata == {} | |
| 134 | + | |
| 135 | + def test_with_metadata(self): | |
| 136 | + a = Artifact( | |
| 137 | + name="Tasks", | |
| 138 | + content="[]", | |
| 139 | + artifact_type="task_list", | |
| 140 | + format="json", | |
| 141 | + metadata={"source": "kg"}, | |
| 142 | + ) | |
| 143 | + assert a.format == "json" | |
| 144 | + assert a.metadata["source"] == "kg" | |
| 145 | + | |
| 146 | + | |
| 147 | +# --------------------------------------------------------------------------- | |
| 148 | +# Skill.can_execute | |
| 149 | +# --------------------------------------------------------------------------- | |
| 150 | + | |
| 151 | + | |
| 152 | +class TestSkillCanExecute: | |
| 153 | + def test_default_requires_kg_and_pm(self): | |
| 154 | + skill = _DummySkill() | |
| 155 | + ctx_no_kg = AgentContext(provider_manager=MagicMock()) | |
| 156 | + assert not skill.can_execute(ctx_no_kg) | |
| 157 | + | |
| 158 | + ctx_no_pm = AgentContext(knowledge_graph=MagicMock()) | |
| 159 | + assert not skill.can_execute(ctx_no_pm) | |
| 160 | + | |
| 161 | + ctx_both = AgentContext(knowledge_graph=MagicMock(), provider_manager=MagicMock()) | |
| 162 | + assert skill.can_execute(ctx_both) | |
| 163 | + | |
| 164 | + | |
| 165 | +# --------------------------------------------------------------------------- | |
| 166 | +# KBContext | |
| 167 | +# --------------------------------------------------------------------------- | |
| 168 | + | |
| 169 | + | |
| 170 | +class TestKBContext: | |
| 171 | + def test_add_source_nonexistent_raises(self, tmp_path): | |
| 172 | + from video_processor.agent.kb_context import KBContext | |
| 173 | + | |
| 174 | + ctx = KBContext() | |
| 175 | + with pytest.raises(FileNotFoundError, match="Not found"): | |
| 176 | + ctx.add_source(tmp_path / "nonexistent.json") | |
| 177 | + | |
| 178 | + def test_add_source_file(self, tmp_path): | |
| 179 | + from video_processor.agent.kb_context import KBContext | |
| 180 | + | |
| 181 | + f = tmp_path / "kg.json" | |
| 182 | + f.write_text("{}") | |
| 183 | + ctx = KBContext() | |
| 184 | + ctx.add_source(f) | |
| 185 | + assert len(ctx.sources) == 1 | |
| 186 | + assert ctx.sources[0] == f.resolve() | |
| 187 | + | |
| 188 | + def test_add_source_directory(self, tmp_path): | |
| 189 | + from video_processor.agent.kb_context import KBContext | |
| 190 | + | |
| 191 | + with patch( | |
| 192 | + "video_processor.integrators.graph_discovery.find_knowledge_graphs", | |
| 193 | + return_value=[tmp_path / "a.db"], | |
| 194 | + ): | |
| 195 | + ctx = KBContext() | |
| 196 | + ctx.add_source(tmp_path) | |
| 197 | + assert len(ctx.sources) == 1 | |
| 198 | + | |
| 199 | + def test_knowledge_graph_before_load_raises(self): | |
| 200 | + from video_processor.agent.kb_context import KBContext | |
| 201 | + | |
| 202 | + ctx = KBContext() | |
| 203 | + with pytest.raises(RuntimeError, match="Call load"): | |
| 204 | + _ = ctx.knowledge_graph | |
| 205 | + | |
| 206 | + def test_query_engine_before_load_raises(self): | |
| 207 | + from video_processor.agent.kb_context import KBContext | |
| 208 | + | |
| 209 | + ctx = KBContext() | |
| 210 | + with pytest.raises(RuntimeError, match="Call load"): | |
| 211 | + _ = ctx.query_engine | |
| 212 | + | |
| 213 | + def test_summary_no_data(self): | |
| 214 | + from video_processor.agent.kb_context import KBContext | |
| 215 | + | |
| 216 | + ctx = KBContext() | |
| 217 | + assert ctx.summary() == "No knowledge base loaded." | |
| 218 | + | |
| 219 | + def test_load_json_and_summary(self, tmp_path): | |
| 220 | + from video_processor.agent.kb_context import KBContext | |
| 221 | + | |
| 222 | + kg_data = {"nodes": [], "relationships": []} | |
| 223 | + f = tmp_path / "kg.json" | |
| 224 | + f.write_text(json.dumps(kg_data)) | |
| 225 | + | |
| 226 | + ctx = KBContext() | |
| 227 | + ctx.add_source(f) | |
| 228 | + ctx.load() | |
| 229 | + | |
| 230 | + summary = ctx.summary() | |
| 231 | + assert "Knowledge base" in summary | |
| 232 | + assert "Entities" in summary | |
| 233 | + assert "Relationships" in summary | |
| 234 | + | |
| 235 | + | |
| 236 | +# --------------------------------------------------------------------------- | |
| 237 | +# PlanningAgent | |
| 238 | +# --------------------------------------------------------------------------- | |
| 239 | + | |
| 240 | + | |
| 241 | +class TestPlanningAgent: | |
| 242 | + def test_from_kb_paths(self, tmp_path): | |
| 243 | + from video_processor.agent.agent_loop import PlanningAgent | |
| 244 | + | |
| 245 | + kg_data = {"nodes": [], "relationships": []} | |
| 246 | + f = tmp_path / "kg.json" | |
| 247 | + f.write_text(json.dumps(kg_data)) | |
| 248 | + | |
| 249 | + agent = PlanningAgent.from_kb_paths([f], provider_manager=None) | |
| 250 | + assert agent.context.knowledge_graph is not None | |
| 251 | + assert agent.context.provider_manager is None | |
| 252 | + | |
| 253 | + def test_execute_with_mock_provider(self, tmp_path): | |
| 254 | + from video_processor.agent.agent_loop import PlanningAgent | |
| 255 | + | |
| 256 | + # Register a dummy skill | |
| 257 | + skill = _DummySkill() | |
| 258 | + register_skill(skill) | |
| 259 | + | |
| 260 | + mock_pm = MagicMock() | |
| 261 | + mock_pm.chat.return_value = json.dumps([{"skill": "dummy_test_skill", "params": {}}]) | |
| 262 | + | |
| 263 | + ctx = AgentContext( | |
| 264 | + knowledge_graph=MagicMock(), | |
| 265 | + query_engine=MagicMock(), | |
| 266 | + provider_manager=mock_pm, | |
| 267 | + ) | |
| 268 | + # Mock stats().to_text() | |
| 269 | + ctx.query_engine.stats.return_value.to_text.return_value = "3 entities" | |
| 270 | + | |
| 271 | + agent = PlanningAgent(context=ctx) | |
| 272 | + artifacts = agent.execute("generate a plan") | |
| 273 | + | |
| 274 | + assert len(artifacts) == 1 | |
| 275 | + assert artifacts[0].name == "dummy artifact" | |
| 276 | + mock_pm.chat.assert_called_once() | |
| 277 | + | |
| 278 | + def test_execute_no_provider_keyword_match(self): | |
| 279 | + from video_processor.agent.agent_loop import PlanningAgent | |
| 280 | + | |
| 281 | + skill = _DummySkill() | |
| 282 | + register_skill(skill) | |
| 283 | + | |
| 284 | + ctx = AgentContext( | |
| 285 | + knowledge_graph=MagicMock(), | |
| 286 | + provider_manager=None, | |
| 287 | + ) | |
| 288 | + | |
| 289 | + agent = PlanningAgent(context=ctx) | |
| 290 | + # "dummy" is a keyword in the skill name, but can_execute needs provider_manager | |
| 291 | + # so it should return empty | |
| 292 | + artifacts = agent.execute("dummy request") | |
| 293 | + assert artifacts == [] | |
| 294 | + | |
| 295 | + def test_execute_keyword_match_nollm_skill(self): | |
| 296 | + from video_processor.agent.agent_loop import PlanningAgent | |
| 297 | + | |
| 298 | + skill = _NoLLMSkill() | |
| 299 | + register_skill(skill) | |
| 300 | + | |
| 301 | + ctx = AgentContext( | |
| 302 | + knowledge_graph=MagicMock(), | |
| 303 | + provider_manager=None, | |
| 304 | + ) | |
| 305 | + | |
| 306 | + agent = PlanningAgent(context=ctx) | |
| 307 | + # "nollm" is in the skill name | |
| 308 | + artifacts = agent.execute("nollm stuff") | |
| 309 | + assert len(artifacts) == 1 | |
| 310 | + assert artifacts[0].name == "nollm artifact" | |
| 311 | + | |
| 312 | + def test_execute_skips_unknown_skills(self): | |
| 313 | + from video_processor.agent.agent_loop import PlanningAgent | |
| 314 | + | |
| 315 | + mock_pm = MagicMock() | |
| 316 | + mock_pm.chat.return_value = json.dumps([{"skill": "nonexistent_skill_xyz", "params": {}}]) | |
| 317 | + | |
| 318 | + ctx = AgentContext( | |
| 319 | + knowledge_graph=MagicMock(), | |
| 320 | + query_engine=MagicMock(), | |
| 321 | + provider_manager=mock_pm, | |
| 322 | + ) | |
| 323 | + ctx.query_engine.stats.return_value.to_text.return_value = "" | |
| 324 | + | |
| 325 | + agent = PlanningAgent(context=ctx) | |
| 326 | + artifacts = agent.execute("do something") | |
| 327 | + assert artifacts == [] | |
| 328 | + | |
| 329 | + def test_chat_no_provider(self): | |
| 330 | + from video_processor.agent.agent_loop import PlanningAgent | |
| 331 | + | |
| 332 | + ctx = AgentContext(provider_manager=None) | |
| 333 | + agent = PlanningAgent(context=ctx) | |
| 334 | + | |
| 335 | + reply = agent.chat("hello") | |
| 336 | + assert "requires" in reply.lower() or "provider" in reply.lower() | |
| 337 | + | |
| 338 | + def test_chat_with_provider(self): | |
| 339 | + from video_processor.agent.agent_loop import PlanningAgent | |
| 340 | + | |
| 341 | + mock_pm = MagicMock() | |
| 342 | + mock_pm.chat.return_value = "I can help you plan." | |
| 343 | + | |
| 344 | + ctx = AgentContext( | |
| 345 | + knowledge_graph=MagicMock(), | |
| 346 | + query_engine=MagicMock(), | |
| 347 | + provider_manager=mock_pm, | |
| 348 | + ) | |
| 349 | + ctx.query_engine.stats.return_value.to_text.return_value = "5 entities" | |
| 350 | + | |
| 351 | + agent = PlanningAgent(context=ctx) | |
| 352 | + reply = agent.chat("help me plan") | |
| 353 | + | |
| 354 | + assert reply == "I can help you plan." | |
| 355 | + assert len(ctx.conversation_history) == 2 # user + assistant | |
| 356 | + assert ctx.conversation_history[0]["role"] == "user" | |
| 357 | + assert ctx.conversation_history[1]["role"] == "assistant" | |
| 358 | + | |
| 359 | + def test_chat_accumulates_history(self): | |
| 360 | + from video_processor.agent.agent_loop import PlanningAgent | |
| 361 | + | |
| 362 | + mock_pm = MagicMock() | |
| 363 | + mock_pm.chat.side_effect = ["reply1", "reply2"] | |
| 364 | + | |
| 365 | + ctx = AgentContext(provider_manager=mock_pm) | |
| 366 | + agent = PlanningAgent(context=ctx) | |
| 367 | + | |
| 368 | + agent.chat("msg1") | |
| 369 | + agent.chat("msg2") | |
| 370 | + | |
| 371 | + assert len(ctx.conversation_history) == 4 # 2 user + 2 assistant | |
| 372 | + # The system message is constructed each time but not stored in history | |
| 373 | + # Provider should receive progressively longer message lists | |
| 374 | + second_call_messages = mock_pm.chat.call_args_list[1][0][0] | |
| 375 | + # Should include system + 3 prior messages (user, assistant, user) | |
| 376 | + assert len(second_call_messages) == 4 # system + user + assistant + user | |
| 377 | + | |
| 5 | 378 | |
| 6 | -from video_processor.agent.orchestrator import AgentOrchestrator | |
| 379 | +# --------------------------------------------------------------------------- | |
| 380 | +# Orchestrator tests (from existing test_agent.py — kept for coverage) | |
| 381 | +# --------------------------------------------------------------------------- | |
| 7 | 382 | |
| 8 | 383 | |
| 9 | 384 | class TestPlanCreation: |
| 10 | 385 | def test_basic_plan(self): |
| 386 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 387 | + | |
| 11 | 388 | agent = AgentOrchestrator() |
| 12 | 389 | plan = agent._create_plan("test.mp4", "basic") |
| 13 | 390 | steps = [s["step"] for s in plan] |
| 14 | 391 | assert "extract_frames" in steps |
| 15 | 392 | assert "extract_audio" in steps |
| @@ -18,18 +395,22 @@ | ||
| 18 | 395 | assert "extract_action_items" in steps |
| 19 | 396 | assert "generate_reports" in steps |
| 20 | 397 | assert "detect_diagrams" not in steps |
| 21 | 398 | |
| 22 | 399 | def test_standard_plan(self): |
| 400 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 401 | + | |
| 23 | 402 | agent = AgentOrchestrator() |
| 24 | 403 | plan = agent._create_plan("test.mp4", "standard") |
| 25 | 404 | steps = [s["step"] for s in plan] |
| 26 | 405 | assert "detect_diagrams" in steps |
| 27 | 406 | assert "build_knowledge_graph" in steps |
| 28 | 407 | assert "deep_analysis" not in steps |
| 29 | 408 | |
| 30 | 409 | def test_comprehensive_plan(self): |
| 410 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 411 | + | |
| 31 | 412 | agent = AgentOrchestrator() |
| 32 | 413 | plan = agent._create_plan("test.mp4", "comprehensive") |
| 33 | 414 | steps = [s["step"] for s in plan] |
| 34 | 415 | assert "detect_diagrams" in steps |
| 35 | 416 | assert "deep_analysis" in steps |
| @@ -36,42 +417,52 @@ | ||
| 36 | 417 | assert "cross_reference" in steps |
| 37 | 418 | |
| 38 | 419 | |
| 39 | 420 | class TestAdaptPlan: |
| 40 | 421 | def test_adapts_for_long_transcript(self): |
| 422 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 423 | + | |
| 41 | 424 | agent = AgentOrchestrator() |
| 42 | 425 | agent._plan = [{"step": "generate_reports", "priority": "required"}] |
| 43 | - long_text = "word " * 3000 # > 10000 chars | |
| 426 | + long_text = "word " * 3000 | |
| 44 | 427 | agent._adapt_plan("transcribe", {"text": long_text}) |
| 45 | 428 | steps = [s["step"] for s in agent._plan] |
| 46 | 429 | assert "deep_analysis" in steps |
| 47 | 430 | |
| 48 | 431 | def test_no_adapt_for_short_transcript(self): |
| 432 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 433 | + | |
| 49 | 434 | agent = AgentOrchestrator() |
| 50 | 435 | agent._plan = [{"step": "generate_reports", "priority": "required"}] |
| 51 | 436 | agent._adapt_plan("transcribe", {"text": "Short text"}) |
| 52 | 437 | steps = [s["step"] for s in agent._plan] |
| 53 | 438 | assert "deep_analysis" not in steps |
| 54 | 439 | |
| 55 | 440 | def test_adapts_for_many_diagrams(self): |
| 441 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 442 | + | |
| 56 | 443 | agent = AgentOrchestrator() |
| 57 | 444 | agent._plan = [{"step": "generate_reports", "priority": "required"}] |
| 58 | 445 | diagrams = [MagicMock() for _ in range(5)] |
| 59 | 446 | agent._adapt_plan("detect_diagrams", {"diagrams": diagrams, "captures": []}) |
| 60 | 447 | steps = [s["step"] for s in agent._plan] |
| 61 | 448 | assert "cross_reference" in steps |
| 62 | 449 | |
| 63 | 450 | def test_insight_for_many_captures(self): |
| 451 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 452 | + | |
| 64 | 453 | agent = AgentOrchestrator() |
| 65 | 454 | agent._plan = [] |
| 66 | 455 | captures = [MagicMock() for _ in range(5)] |
| 67 | 456 | diagrams = [MagicMock() for _ in range(2)] |
| 68 | 457 | agent._adapt_plan("detect_diagrams", {"diagrams": diagrams, "captures": captures}) |
| 69 | 458 | assert len(agent._insights) == 1 |
| 70 | 459 | assert "uncertain frames" in agent._insights[0] |
| 71 | 460 | |
| 72 | 461 | def test_no_duplicate_steps(self): |
| 462 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 463 | + | |
| 73 | 464 | agent = AgentOrchestrator() |
| 74 | 465 | agent._plan = [{"step": "deep_analysis", "priority": "comprehensive"}] |
| 75 | 466 | long_text = "word " * 3000 |
| 76 | 467 | agent._adapt_plan("transcribe", {"text": long_text}) |
| 77 | 468 | deep_steps = [s for s in agent._plan if s["step"] == "deep_analysis"] |
| @@ -78,28 +469,35 @@ | ||
| 78 | 469 | assert len(deep_steps) == 1 |
| 79 | 470 | |
| 80 | 471 | |
| 81 | 472 | class TestFallbacks: |
| 82 | 473 | def test_diagram_fallback(self): |
| 474 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 475 | + | |
| 83 | 476 | agent = AgentOrchestrator() |
| 84 | 477 | assert agent._get_fallback("detect_diagrams") == "screengrab_fallback" |
| 85 | 478 | |
| 86 | 479 | def test_no_fallback_for_unknown(self): |
| 480 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 481 | + | |
| 87 | 482 | agent = AgentOrchestrator() |
| 88 | 483 | assert agent._get_fallback("transcribe") is None |
| 89 | 484 | |
| 90 | 485 | |
| 91 | 486 | class TestInsights: |
| 92 | 487 | def test_insights_property(self): |
| 488 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 489 | + | |
| 93 | 490 | agent = AgentOrchestrator() |
| 94 | 491 | agent._insights = ["Insight 1", "Insight 2"] |
| 95 | 492 | assert agent.insights == ["Insight 1", "Insight 2"] |
| 96 | - # Should return a copy | |
| 97 | 493 | agent.insights.append("should not modify internal") |
| 98 | 494 | assert len(agent._insights) == 2 |
| 99 | 495 | |
| 100 | 496 | def test_deep_analysis_populates_insights(self): |
| 497 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 498 | + | |
| 101 | 499 | pm = MagicMock() |
| 102 | 500 | pm.chat.return_value = json.dumps( |
| 103 | 501 | { |
| 104 | 502 | "decisions": ["Decided to use microservices"], |
| 105 | 503 | "risks": ["Timeline is tight"], |
| @@ -113,57 +511,61 @@ | ||
| 113 | 511 | assert "decisions" in result |
| 114 | 512 | assert any("microservices" in i for i in agent._insights) |
| 115 | 513 | assert any("Timeline" in i for i in agent._insights) |
| 116 | 514 | |
| 117 | 515 | def test_deep_analysis_handles_error(self): |
| 516 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 517 | + | |
| 118 | 518 | pm = MagicMock() |
| 119 | 519 | pm.chat.side_effect = Exception("API error") |
| 120 | 520 | agent = AgentOrchestrator(provider_manager=pm) |
| 121 | 521 | agent._results["transcribe"] = {"text": "some text"} |
| 122 | 522 | result = agent._deep_analysis("/tmp") |
| 123 | 523 | assert result == {} |
| 124 | 524 | |
| 125 | 525 | def test_deep_analysis_no_transcript(self): |
| 526 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 527 | + | |
| 126 | 528 | agent = AgentOrchestrator() |
| 127 | 529 | agent._results["transcribe"] = {"text": ""} |
| 128 | 530 | result = agent._deep_analysis("/tmp") |
| 129 | 531 | assert result == {} |
| 130 | 532 | |
| 131 | 533 | |
| 132 | 534 | class TestBuildManifest: |
| 133 | 535 | def test_builds_from_results(self): |
| 536 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 537 | + | |
| 134 | 538 | agent = AgentOrchestrator() |
| 135 | 539 | agent._results = { |
| 136 | 540 | "extract_frames": {"frames": [1, 2, 3], "paths": ["/a.jpg", "/b.jpg"]}, |
| 137 | 541 | "extract_audio": {"audio_path": "/audio.wav", "properties": {"duration": 60.0}}, |
| 138 | 542 | "detect_diagrams": {"diagrams": [], "captures": []}, |
| 139 | 543 | "extract_key_points": {"key_points": []}, |
| 140 | 544 | "extract_action_items": {"action_items": []}, |
| 141 | 545 | } |
| 142 | - from pathlib import Path | |
| 143 | - | |
| 144 | 546 | manifest = agent._build_manifest(Path("test.mp4"), Path("/out"), "Test", 5.0) |
| 145 | 547 | assert manifest.video.title == "Test" |
| 146 | 548 | assert manifest.stats.frames_extracted == 3 |
| 147 | 549 | assert manifest.stats.duration_seconds == 5.0 |
| 148 | 550 | assert manifest.video.duration_seconds == 60.0 |
| 149 | 551 | |
| 150 | 552 | def test_handles_missing_results(self): |
| 553 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 554 | + | |
| 151 | 555 | agent = AgentOrchestrator() |
| 152 | 556 | agent._results = {} |
| 153 | - from pathlib import Path | |
| 154 | - | |
| 155 | 557 | manifest = agent._build_manifest(Path("test.mp4"), Path("/out"), None, 1.0) |
| 156 | 558 | assert manifest.video.title == "Analysis of test" |
| 157 | 559 | assert manifest.stats.frames_extracted == 0 |
| 158 | 560 | |
| 159 | 561 | def test_handles_error_results(self): |
| 562 | + from video_processor.agent.orchestrator import AgentOrchestrator | |
| 563 | + | |
| 160 | 564 | agent = AgentOrchestrator() |
| 161 | 565 | agent._results = { |
| 162 | 566 | "extract_frames": {"error": "failed"}, |
| 163 | 567 | "detect_diagrams": {"error": "also failed"}, |
| 164 | 568 | } |
| 165 | - from pathlib import Path | |
| 166 | - | |
| 167 | 569 | manifest = agent._build_manifest(Path("vid.mp4"), Path("/out"), None, 2.0) |
| 168 | 570 | assert manifest.stats.frames_extracted == 0 |
| 169 | 571 | assert len(manifest.diagrams) == 0 |
| 170 | 572 | |
| 171 | 573 | ADDED tests/test_agent_skills.py |
| 172 | 574 | ADDED tests/test_api_spec.py |
| 173 | 575 | ADDED tests/test_auth.py |
| 174 | 576 | ADDED tests/test_callbacks.py |
| 175 | 577 | ADDED tests/test_cli.py |
| 176 | 578 | ADDED tests/test_companion.py |
| 177 | 579 | ADDED tests/test_exchange.py |
| --- tests/test_agent.py | |
| +++ tests/test_agent.py | |
| @@ -1,15 +1,392 @@ | |
| 1 | """Tests for the agentic processing orchestrator.""" |
| 2 | |
| 3 | import json |
| 4 | from unittest.mock import MagicMock |
| 5 | |
| 6 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 7 | |
| 8 | |
| 9 | class TestPlanCreation: |
| 10 | def test_basic_plan(self): |
| 11 | agent = AgentOrchestrator() |
| 12 | plan = agent._create_plan("test.mp4", "basic") |
| 13 | steps = [s["step"] for s in plan] |
| 14 | assert "extract_frames" in steps |
| 15 | assert "extract_audio" in steps |
| @@ -18,18 +395,22 @@ | |
| 18 | assert "extract_action_items" in steps |
| 19 | assert "generate_reports" in steps |
| 20 | assert "detect_diagrams" not in steps |
| 21 | |
| 22 | def test_standard_plan(self): |
| 23 | agent = AgentOrchestrator() |
| 24 | plan = agent._create_plan("test.mp4", "standard") |
| 25 | steps = [s["step"] for s in plan] |
| 26 | assert "detect_diagrams" in steps |
| 27 | assert "build_knowledge_graph" in steps |
| 28 | assert "deep_analysis" not in steps |
| 29 | |
| 30 | def test_comprehensive_plan(self): |
| 31 | agent = AgentOrchestrator() |
| 32 | plan = agent._create_plan("test.mp4", "comprehensive") |
| 33 | steps = [s["step"] for s in plan] |
| 34 | assert "detect_diagrams" in steps |
| 35 | assert "deep_analysis" in steps |
| @@ -36,42 +417,52 @@ | |
| 36 | assert "cross_reference" in steps |
| 37 | |
| 38 | |
| 39 | class TestAdaptPlan: |
| 40 | def test_adapts_for_long_transcript(self): |
| 41 | agent = AgentOrchestrator() |
| 42 | agent._plan = [{"step": "generate_reports", "priority": "required"}] |
| 43 | long_text = "word " * 3000 # > 10000 chars |
| 44 | agent._adapt_plan("transcribe", {"text": long_text}) |
| 45 | steps = [s["step"] for s in agent._plan] |
| 46 | assert "deep_analysis" in steps |
| 47 | |
| 48 | def test_no_adapt_for_short_transcript(self): |
| 49 | agent = AgentOrchestrator() |
| 50 | agent._plan = [{"step": "generate_reports", "priority": "required"}] |
| 51 | agent._adapt_plan("transcribe", {"text": "Short text"}) |
| 52 | steps = [s["step"] for s in agent._plan] |
| 53 | assert "deep_analysis" not in steps |
| 54 | |
| 55 | def test_adapts_for_many_diagrams(self): |
| 56 | agent = AgentOrchestrator() |
| 57 | agent._plan = [{"step": "generate_reports", "priority": "required"}] |
| 58 | diagrams = [MagicMock() for _ in range(5)] |
| 59 | agent._adapt_plan("detect_diagrams", {"diagrams": diagrams, "captures": []}) |
| 60 | steps = [s["step"] for s in agent._plan] |
| 61 | assert "cross_reference" in steps |
| 62 | |
| 63 | def test_insight_for_many_captures(self): |
| 64 | agent = AgentOrchestrator() |
| 65 | agent._plan = [] |
| 66 | captures = [MagicMock() for _ in range(5)] |
| 67 | diagrams = [MagicMock() for _ in range(2)] |
| 68 | agent._adapt_plan("detect_diagrams", {"diagrams": diagrams, "captures": captures}) |
| 69 | assert len(agent._insights) == 1 |
| 70 | assert "uncertain frames" in agent._insights[0] |
| 71 | |
| 72 | def test_no_duplicate_steps(self): |
| 73 | agent = AgentOrchestrator() |
| 74 | agent._plan = [{"step": "deep_analysis", "priority": "comprehensive"}] |
| 75 | long_text = "word " * 3000 |
| 76 | agent._adapt_plan("transcribe", {"text": long_text}) |
| 77 | deep_steps = [s for s in agent._plan if s["step"] == "deep_analysis"] |
| @@ -78,28 +469,35 @@ | |
| 78 | assert len(deep_steps) == 1 |
| 79 | |
| 80 | |
| 81 | class TestFallbacks: |
| 82 | def test_diagram_fallback(self): |
| 83 | agent = AgentOrchestrator() |
| 84 | assert agent._get_fallback("detect_diagrams") == "screengrab_fallback" |
| 85 | |
| 86 | def test_no_fallback_for_unknown(self): |
| 87 | agent = AgentOrchestrator() |
| 88 | assert agent._get_fallback("transcribe") is None |
| 89 | |
| 90 | |
| 91 | class TestInsights: |
| 92 | def test_insights_property(self): |
| 93 | agent = AgentOrchestrator() |
| 94 | agent._insights = ["Insight 1", "Insight 2"] |
| 95 | assert agent.insights == ["Insight 1", "Insight 2"] |
| 96 | # Should return a copy |
| 97 | agent.insights.append("should not modify internal") |
| 98 | assert len(agent._insights) == 2 |
| 99 | |
| 100 | def test_deep_analysis_populates_insights(self): |
| 101 | pm = MagicMock() |
| 102 | pm.chat.return_value = json.dumps( |
| 103 | { |
| 104 | "decisions": ["Decided to use microservices"], |
| 105 | "risks": ["Timeline is tight"], |
| @@ -113,57 +511,61 @@ | |
| 113 | assert "decisions" in result |
| 114 | assert any("microservices" in i for i in agent._insights) |
| 115 | assert any("Timeline" in i for i in agent._insights) |
| 116 | |
| 117 | def test_deep_analysis_handles_error(self): |
| 118 | pm = MagicMock() |
| 119 | pm.chat.side_effect = Exception("API error") |
| 120 | agent = AgentOrchestrator(provider_manager=pm) |
| 121 | agent._results["transcribe"] = {"text": "some text"} |
| 122 | result = agent._deep_analysis("/tmp") |
| 123 | assert result == {} |
| 124 | |
| 125 | def test_deep_analysis_no_transcript(self): |
| 126 | agent = AgentOrchestrator() |
| 127 | agent._results["transcribe"] = {"text": ""} |
| 128 | result = agent._deep_analysis("/tmp") |
| 129 | assert result == {} |
| 130 | |
| 131 | |
| 132 | class TestBuildManifest: |
| 133 | def test_builds_from_results(self): |
| 134 | agent = AgentOrchestrator() |
| 135 | agent._results = { |
| 136 | "extract_frames": {"frames": [1, 2, 3], "paths": ["/a.jpg", "/b.jpg"]}, |
| 137 | "extract_audio": {"audio_path": "/audio.wav", "properties": {"duration": 60.0}}, |
| 138 | "detect_diagrams": {"diagrams": [], "captures": []}, |
| 139 | "extract_key_points": {"key_points": []}, |
| 140 | "extract_action_items": {"action_items": []}, |
| 141 | } |
| 142 | from pathlib import Path |
| 143 | |
| 144 | manifest = agent._build_manifest(Path("test.mp4"), Path("/out"), "Test", 5.0) |
| 145 | assert manifest.video.title == "Test" |
| 146 | assert manifest.stats.frames_extracted == 3 |
| 147 | assert manifest.stats.duration_seconds == 5.0 |
| 148 | assert manifest.video.duration_seconds == 60.0 |
| 149 | |
| 150 | def test_handles_missing_results(self): |
| 151 | agent = AgentOrchestrator() |
| 152 | agent._results = {} |
| 153 | from pathlib import Path |
| 154 | |
| 155 | manifest = agent._build_manifest(Path("test.mp4"), Path("/out"), None, 1.0) |
| 156 | assert manifest.video.title == "Analysis of test" |
| 157 | assert manifest.stats.frames_extracted == 0 |
| 158 | |
| 159 | def test_handles_error_results(self): |
| 160 | agent = AgentOrchestrator() |
| 161 | agent._results = { |
| 162 | "extract_frames": {"error": "failed"}, |
| 163 | "detect_diagrams": {"error": "also failed"}, |
| 164 | } |
| 165 | from pathlib import Path |
| 166 | |
| 167 | manifest = agent._build_manifest(Path("vid.mp4"), Path("/out"), None, 2.0) |
| 168 | assert manifest.stats.frames_extracted == 0 |
| 169 | assert len(manifest.diagrams) == 0 |
| 170 | |
| 171 | DDED tests/test_agent_skills.py |
| 172 | DDED tests/test_api_spec.py |
| 173 | DDED tests/test_auth.py |
| 174 | DDED tests/test_callbacks.py |
| 175 | DDED tests/test_cli.py |
| 176 | DDED tests/test_companion.py |
| 177 | DDED tests/test_exchange.py |
| --- tests/test_agent.py | |
| +++ tests/test_agent.py | |
| @@ -1,15 +1,392 @@ | |
| 1 | """Tests for the planning agent, skill registry, KB context, and agent loop.""" |
| 2 | |
| 3 | import json |
| 4 | from pathlib import Path |
| 5 | from unittest.mock import MagicMock, patch |
| 6 | |
| 7 | import pytest |
| 8 | |
| 9 | from video_processor.agent.skills.base import ( |
| 10 | AgentContext, |
| 11 | Artifact, |
| 12 | Skill, |
| 13 | _skills, |
| 14 | get_skill, |
| 15 | list_skills, |
| 16 | register_skill, |
| 17 | ) |
| 18 | |
| 19 | # --------------------------------------------------------------------------- |
| 20 | # Fixtures |
| 21 | # --------------------------------------------------------------------------- |
| 22 | |
| 23 | |
| 24 | @pytest.fixture(autouse=True) |
| 25 | def _clean_skill_registry(): |
| 26 | """Save and restore the global skill registry between tests.""" |
| 27 | original = dict(_skills) |
| 28 | yield |
| 29 | _skills.clear() |
| 30 | _skills.update(original) |
| 31 | |
| 32 | |
| 33 | class _DummySkill(Skill): |
| 34 | name = "dummy_test_skill" |
| 35 | description = "A dummy skill for testing" |
| 36 | |
| 37 | def execute(self, context: AgentContext, **kwargs) -> Artifact: |
| 38 | return Artifact( |
| 39 | name="dummy artifact", |
| 40 | content="dummy content", |
| 41 | artifact_type="document", |
| 42 | ) |
| 43 | |
| 44 | |
| 45 | class _NoLLMSkill(Skill): |
| 46 | """Skill that doesn't require provider_manager.""" |
| 47 | |
| 48 | name = "nollm_skill" |
| 49 | description = "Works without LLM" |
| 50 | |
| 51 | def execute(self, context: AgentContext, **kwargs) -> Artifact: |
| 52 | return Artifact( |
| 53 | name="nollm artifact", |
| 54 | content="generated", |
| 55 | artifact_type="document", |
| 56 | ) |
| 57 | |
| 58 | def can_execute(self, context: AgentContext) -> bool: |
| 59 | return context.knowledge_graph is not None |
| 60 | |
| 61 | |
| 62 | # --------------------------------------------------------------------------- |
| 63 | # Skill registry |
| 64 | # --------------------------------------------------------------------------- |
| 65 | |
| 66 | |
| 67 | class TestSkillRegistry: |
| 68 | def test_register_and_get(self): |
| 69 | skill = _DummySkill() |
| 70 | register_skill(skill) |
| 71 | assert get_skill("dummy_test_skill") is skill |
| 72 | |
| 73 | def test_get_unknown_returns_none(self): |
| 74 | assert get_skill("no_such_skill_xyz") is None |
| 75 | |
| 76 | def test_list_skills(self): |
| 77 | s1 = _DummySkill() |
| 78 | register_skill(s1) |
| 79 | skills = list_skills() |
| 80 | assert any(s.name == "dummy_test_skill" for s in skills) |
| 81 | |
| 82 | def test_list_skills_empty(self): |
| 83 | _skills.clear() |
| 84 | assert list_skills() == [] |
| 85 | |
| 86 | |
| 87 | # --------------------------------------------------------------------------- |
| 88 | # AgentContext dataclass |
| 89 | # --------------------------------------------------------------------------- |
| 90 | |
| 91 | |
| 92 | class TestAgentContext: |
| 93 | def test_defaults(self): |
| 94 | ctx = AgentContext() |
| 95 | assert ctx.knowledge_graph is None |
| 96 | assert ctx.query_engine is None |
| 97 | assert ctx.provider_manager is None |
| 98 | assert ctx.planning_entities == [] |
| 99 | assert ctx.user_requirements == {} |
| 100 | assert ctx.conversation_history == [] |
| 101 | assert ctx.artifacts == [] |
| 102 | assert ctx.config == {} |
| 103 | |
| 104 | def test_with_values(self): |
| 105 | mock_kg = MagicMock() |
| 106 | mock_qe = MagicMock() |
| 107 | mock_pm = MagicMock() |
| 108 | ctx = AgentContext( |
| 109 | knowledge_graph=mock_kg, |
| 110 | query_engine=mock_qe, |
| 111 | provider_manager=mock_pm, |
| 112 | config={"key": "value"}, |
| 113 | ) |
| 114 | assert ctx.knowledge_graph is mock_kg |
| 115 | assert ctx.config == {"key": "value"} |
| 116 | |
| 117 | def test_conversation_history_is_mutable(self): |
| 118 | ctx = AgentContext() |
| 119 | ctx.conversation_history.append({"role": "user", "content": "hello"}) |
| 120 | assert len(ctx.conversation_history) == 1 |
| 121 | |
| 122 | |
| 123 | # --------------------------------------------------------------------------- |
| 124 | # Artifact dataclass |
| 125 | # --------------------------------------------------------------------------- |
| 126 | |
| 127 | |
| 128 | class TestArtifact: |
| 129 | def test_basic(self): |
| 130 | a = Artifact(name="Plan", content="# Plan\n...", artifact_type="project_plan") |
| 131 | assert a.name == "Plan" |
| 132 | assert a.format == "markdown" # default |
| 133 | assert a.metadata == {} |
| 134 | |
| 135 | def test_with_metadata(self): |
| 136 | a = Artifact( |
| 137 | name="Tasks", |
| 138 | content="[]", |
| 139 | artifact_type="task_list", |
| 140 | format="json", |
| 141 | metadata={"source": "kg"}, |
| 142 | ) |
| 143 | assert a.format == "json" |
| 144 | assert a.metadata["source"] == "kg" |
| 145 | |
| 146 | |
| 147 | # --------------------------------------------------------------------------- |
| 148 | # Skill.can_execute |
| 149 | # --------------------------------------------------------------------------- |
| 150 | |
| 151 | |
| 152 | class TestSkillCanExecute: |
| 153 | def test_default_requires_kg_and_pm(self): |
| 154 | skill = _DummySkill() |
| 155 | ctx_no_kg = AgentContext(provider_manager=MagicMock()) |
| 156 | assert not skill.can_execute(ctx_no_kg) |
| 157 | |
| 158 | ctx_no_pm = AgentContext(knowledge_graph=MagicMock()) |
| 159 | assert not skill.can_execute(ctx_no_pm) |
| 160 | |
| 161 | ctx_both = AgentContext(knowledge_graph=MagicMock(), provider_manager=MagicMock()) |
| 162 | assert skill.can_execute(ctx_both) |
| 163 | |
| 164 | |
| 165 | # --------------------------------------------------------------------------- |
| 166 | # KBContext |
| 167 | # --------------------------------------------------------------------------- |
| 168 | |
| 169 | |
| 170 | class TestKBContext: |
| 171 | def test_add_source_nonexistent_raises(self, tmp_path): |
| 172 | from video_processor.agent.kb_context import KBContext |
| 173 | |
| 174 | ctx = KBContext() |
| 175 | with pytest.raises(FileNotFoundError, match="Not found"): |
| 176 | ctx.add_source(tmp_path / "nonexistent.json") |
| 177 | |
| 178 | def test_add_source_file(self, tmp_path): |
| 179 | from video_processor.agent.kb_context import KBContext |
| 180 | |
| 181 | f = tmp_path / "kg.json" |
| 182 | f.write_text("{}") |
| 183 | ctx = KBContext() |
| 184 | ctx.add_source(f) |
| 185 | assert len(ctx.sources) == 1 |
| 186 | assert ctx.sources[0] == f.resolve() |
| 187 | |
| 188 | def test_add_source_directory(self, tmp_path): |
| 189 | from video_processor.agent.kb_context import KBContext |
| 190 | |
| 191 | with patch( |
| 192 | "video_processor.integrators.graph_discovery.find_knowledge_graphs", |
| 193 | return_value=[tmp_path / "a.db"], |
| 194 | ): |
| 195 | ctx = KBContext() |
| 196 | ctx.add_source(tmp_path) |
| 197 | assert len(ctx.sources) == 1 |
| 198 | |
| 199 | def test_knowledge_graph_before_load_raises(self): |
| 200 | from video_processor.agent.kb_context import KBContext |
| 201 | |
| 202 | ctx = KBContext() |
| 203 | with pytest.raises(RuntimeError, match="Call load"): |
| 204 | _ = ctx.knowledge_graph |
| 205 | |
| 206 | def test_query_engine_before_load_raises(self): |
| 207 | from video_processor.agent.kb_context import KBContext |
| 208 | |
| 209 | ctx = KBContext() |
| 210 | with pytest.raises(RuntimeError, match="Call load"): |
| 211 | _ = ctx.query_engine |
| 212 | |
| 213 | def test_summary_no_data(self): |
| 214 | from video_processor.agent.kb_context import KBContext |
| 215 | |
| 216 | ctx = KBContext() |
| 217 | assert ctx.summary() == "No knowledge base loaded." |
| 218 | |
| 219 | def test_load_json_and_summary(self, tmp_path): |
| 220 | from video_processor.agent.kb_context import KBContext |
| 221 | |
| 222 | kg_data = {"nodes": [], "relationships": []} |
| 223 | f = tmp_path / "kg.json" |
| 224 | f.write_text(json.dumps(kg_data)) |
| 225 | |
| 226 | ctx = KBContext() |
| 227 | ctx.add_source(f) |
| 228 | ctx.load() |
| 229 | |
| 230 | summary = ctx.summary() |
| 231 | assert "Knowledge base" in summary |
| 232 | assert "Entities" in summary |
| 233 | assert "Relationships" in summary |
| 234 | |
| 235 | |
| 236 | # --------------------------------------------------------------------------- |
| 237 | # PlanningAgent |
| 238 | # --------------------------------------------------------------------------- |
| 239 | |
| 240 | |
| 241 | class TestPlanningAgent: |
| 242 | def test_from_kb_paths(self, tmp_path): |
| 243 | from video_processor.agent.agent_loop import PlanningAgent |
| 244 | |
| 245 | kg_data = {"nodes": [], "relationships": []} |
| 246 | f = tmp_path / "kg.json" |
| 247 | f.write_text(json.dumps(kg_data)) |
| 248 | |
| 249 | agent = PlanningAgent.from_kb_paths([f], provider_manager=None) |
| 250 | assert agent.context.knowledge_graph is not None |
| 251 | assert agent.context.provider_manager is None |
| 252 | |
| 253 | def test_execute_with_mock_provider(self, tmp_path): |
| 254 | from video_processor.agent.agent_loop import PlanningAgent |
| 255 | |
| 256 | # Register a dummy skill |
| 257 | skill = _DummySkill() |
| 258 | register_skill(skill) |
| 259 | |
| 260 | mock_pm = MagicMock() |
| 261 | mock_pm.chat.return_value = json.dumps([{"skill": "dummy_test_skill", "params": {}}]) |
| 262 | |
| 263 | ctx = AgentContext( |
| 264 | knowledge_graph=MagicMock(), |
| 265 | query_engine=MagicMock(), |
| 266 | provider_manager=mock_pm, |
| 267 | ) |
| 268 | # Mock stats().to_text() |
| 269 | ctx.query_engine.stats.return_value.to_text.return_value = "3 entities" |
| 270 | |
| 271 | agent = PlanningAgent(context=ctx) |
| 272 | artifacts = agent.execute("generate a plan") |
| 273 | |
| 274 | assert len(artifacts) == 1 |
| 275 | assert artifacts[0].name == "dummy artifact" |
| 276 | mock_pm.chat.assert_called_once() |
| 277 | |
| 278 | def test_execute_no_provider_keyword_match(self): |
| 279 | from video_processor.agent.agent_loop import PlanningAgent |
| 280 | |
| 281 | skill = _DummySkill() |
| 282 | register_skill(skill) |
| 283 | |
| 284 | ctx = AgentContext( |
| 285 | knowledge_graph=MagicMock(), |
| 286 | provider_manager=None, |
| 287 | ) |
| 288 | |
| 289 | agent = PlanningAgent(context=ctx) |
| 290 | # "dummy" is a keyword in the skill name, but can_execute needs provider_manager |
| 291 | # so it should return empty |
| 292 | artifacts = agent.execute("dummy request") |
| 293 | assert artifacts == [] |
| 294 | |
| 295 | def test_execute_keyword_match_nollm_skill(self): |
| 296 | from video_processor.agent.agent_loop import PlanningAgent |
| 297 | |
| 298 | skill = _NoLLMSkill() |
| 299 | register_skill(skill) |
| 300 | |
| 301 | ctx = AgentContext( |
| 302 | knowledge_graph=MagicMock(), |
| 303 | provider_manager=None, |
| 304 | ) |
| 305 | |
| 306 | agent = PlanningAgent(context=ctx) |
| 307 | # "nollm" is in the skill name |
| 308 | artifacts = agent.execute("nollm stuff") |
| 309 | assert len(artifacts) == 1 |
| 310 | assert artifacts[0].name == "nollm artifact" |
| 311 | |
| 312 | def test_execute_skips_unknown_skills(self): |
| 313 | from video_processor.agent.agent_loop import PlanningAgent |
| 314 | |
| 315 | mock_pm = MagicMock() |
| 316 | mock_pm.chat.return_value = json.dumps([{"skill": "nonexistent_skill_xyz", "params": {}}]) |
| 317 | |
| 318 | ctx = AgentContext( |
| 319 | knowledge_graph=MagicMock(), |
| 320 | query_engine=MagicMock(), |
| 321 | provider_manager=mock_pm, |
| 322 | ) |
| 323 | ctx.query_engine.stats.return_value.to_text.return_value = "" |
| 324 | |
| 325 | agent = PlanningAgent(context=ctx) |
| 326 | artifacts = agent.execute("do something") |
| 327 | assert artifacts == [] |
| 328 | |
| 329 | def test_chat_no_provider(self): |
| 330 | from video_processor.agent.agent_loop import PlanningAgent |
| 331 | |
| 332 | ctx = AgentContext(provider_manager=None) |
| 333 | agent = PlanningAgent(context=ctx) |
| 334 | |
| 335 | reply = agent.chat("hello") |
| 336 | assert "requires" in reply.lower() or "provider" in reply.lower() |
| 337 | |
| 338 | def test_chat_with_provider(self): |
| 339 | from video_processor.agent.agent_loop import PlanningAgent |
| 340 | |
| 341 | mock_pm = MagicMock() |
| 342 | mock_pm.chat.return_value = "I can help you plan." |
| 343 | |
| 344 | ctx = AgentContext( |
| 345 | knowledge_graph=MagicMock(), |
| 346 | query_engine=MagicMock(), |
| 347 | provider_manager=mock_pm, |
| 348 | ) |
| 349 | ctx.query_engine.stats.return_value.to_text.return_value = "5 entities" |
| 350 | |
| 351 | agent = PlanningAgent(context=ctx) |
| 352 | reply = agent.chat("help me plan") |
| 353 | |
| 354 | assert reply == "I can help you plan." |
| 355 | assert len(ctx.conversation_history) == 2 # user + assistant |
| 356 | assert ctx.conversation_history[0]["role"] == "user" |
| 357 | assert ctx.conversation_history[1]["role"] == "assistant" |
| 358 | |
| 359 | def test_chat_accumulates_history(self): |
| 360 | from video_processor.agent.agent_loop import PlanningAgent |
| 361 | |
| 362 | mock_pm = MagicMock() |
| 363 | mock_pm.chat.side_effect = ["reply1", "reply2"] |
| 364 | |
| 365 | ctx = AgentContext(provider_manager=mock_pm) |
| 366 | agent = PlanningAgent(context=ctx) |
| 367 | |
| 368 | agent.chat("msg1") |
| 369 | agent.chat("msg2") |
| 370 | |
| 371 | assert len(ctx.conversation_history) == 4 # 2 user + 2 assistant |
| 372 | # The system message is constructed each time but not stored in history |
| 373 | # Provider should receive progressively longer message lists |
| 374 | second_call_messages = mock_pm.chat.call_args_list[1][0][0] |
| 375 | # Should include system + 3 prior messages (user, assistant, user) |
| 376 | assert len(second_call_messages) == 4 # system + user + assistant + user |
| 377 | |
| 378 | |
| 379 | # --------------------------------------------------------------------------- |
| 380 | # Orchestrator tests (from existing test_agent.py — kept for coverage) |
| 381 | # --------------------------------------------------------------------------- |
| 382 | |
| 383 | |
| 384 | class TestPlanCreation: |
| 385 | def test_basic_plan(self): |
| 386 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 387 | |
| 388 | agent = AgentOrchestrator() |
| 389 | plan = agent._create_plan("test.mp4", "basic") |
| 390 | steps = [s["step"] for s in plan] |
| 391 | assert "extract_frames" in steps |
| 392 | assert "extract_audio" in steps |
| @@ -18,18 +395,22 @@ | |
| 395 | assert "extract_action_items" in steps |
| 396 | assert "generate_reports" in steps |
| 397 | assert "detect_diagrams" not in steps |
| 398 | |
| 399 | def test_standard_plan(self): |
| 400 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 401 | |
| 402 | agent = AgentOrchestrator() |
| 403 | plan = agent._create_plan("test.mp4", "standard") |
| 404 | steps = [s["step"] for s in plan] |
| 405 | assert "detect_diagrams" in steps |
| 406 | assert "build_knowledge_graph" in steps |
| 407 | assert "deep_analysis" not in steps |
| 408 | |
| 409 | def test_comprehensive_plan(self): |
| 410 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 411 | |
| 412 | agent = AgentOrchestrator() |
| 413 | plan = agent._create_plan("test.mp4", "comprehensive") |
| 414 | steps = [s["step"] for s in plan] |
| 415 | assert "detect_diagrams" in steps |
| 416 | assert "deep_analysis" in steps |
| @@ -36,42 +417,52 @@ | |
| 417 | assert "cross_reference" in steps |
| 418 | |
| 419 | |
| 420 | class TestAdaptPlan: |
| 421 | def test_adapts_for_long_transcript(self): |
| 422 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 423 | |
| 424 | agent = AgentOrchestrator() |
| 425 | agent._plan = [{"step": "generate_reports", "priority": "required"}] |
| 426 | long_text = "word " * 3000 |
| 427 | agent._adapt_plan("transcribe", {"text": long_text}) |
| 428 | steps = [s["step"] for s in agent._plan] |
| 429 | assert "deep_analysis" in steps |
| 430 | |
| 431 | def test_no_adapt_for_short_transcript(self): |
| 432 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 433 | |
| 434 | agent = AgentOrchestrator() |
| 435 | agent._plan = [{"step": "generate_reports", "priority": "required"}] |
| 436 | agent._adapt_plan("transcribe", {"text": "Short text"}) |
| 437 | steps = [s["step"] for s in agent._plan] |
| 438 | assert "deep_analysis" not in steps |
| 439 | |
| 440 | def test_adapts_for_many_diagrams(self): |
| 441 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 442 | |
| 443 | agent = AgentOrchestrator() |
| 444 | agent._plan = [{"step": "generate_reports", "priority": "required"}] |
| 445 | diagrams = [MagicMock() for _ in range(5)] |
| 446 | agent._adapt_plan("detect_diagrams", {"diagrams": diagrams, "captures": []}) |
| 447 | steps = [s["step"] for s in agent._plan] |
| 448 | assert "cross_reference" in steps |
| 449 | |
| 450 | def test_insight_for_many_captures(self): |
| 451 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 452 | |
| 453 | agent = AgentOrchestrator() |
| 454 | agent._plan = [] |
| 455 | captures = [MagicMock() for _ in range(5)] |
| 456 | diagrams = [MagicMock() for _ in range(2)] |
| 457 | agent._adapt_plan("detect_diagrams", {"diagrams": diagrams, "captures": captures}) |
| 458 | assert len(agent._insights) == 1 |
| 459 | assert "uncertain frames" in agent._insights[0] |
| 460 | |
| 461 | def test_no_duplicate_steps(self): |
| 462 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 463 | |
| 464 | agent = AgentOrchestrator() |
| 465 | agent._plan = [{"step": "deep_analysis", "priority": "comprehensive"}] |
| 466 | long_text = "word " * 3000 |
| 467 | agent._adapt_plan("transcribe", {"text": long_text}) |
| 468 | deep_steps = [s for s in agent._plan if s["step"] == "deep_analysis"] |
| @@ -78,28 +469,35 @@ | |
| 469 | assert len(deep_steps) == 1 |
| 470 | |
| 471 | |
| 472 | class TestFallbacks: |
| 473 | def test_diagram_fallback(self): |
| 474 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 475 | |
| 476 | agent = AgentOrchestrator() |
| 477 | assert agent._get_fallback("detect_diagrams") == "screengrab_fallback" |
| 478 | |
| 479 | def test_no_fallback_for_unknown(self): |
| 480 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 481 | |
| 482 | agent = AgentOrchestrator() |
| 483 | assert agent._get_fallback("transcribe") is None |
| 484 | |
| 485 | |
| 486 | class TestInsights: |
| 487 | def test_insights_property(self): |
| 488 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 489 | |
| 490 | agent = AgentOrchestrator() |
| 491 | agent._insights = ["Insight 1", "Insight 2"] |
| 492 | assert agent.insights == ["Insight 1", "Insight 2"] |
| 493 | agent.insights.append("should not modify internal") |
| 494 | assert len(agent._insights) == 2 |
| 495 | |
| 496 | def test_deep_analysis_populates_insights(self): |
| 497 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 498 | |
| 499 | pm = MagicMock() |
| 500 | pm.chat.return_value = json.dumps( |
| 501 | { |
| 502 | "decisions": ["Decided to use microservices"], |
| 503 | "risks": ["Timeline is tight"], |
| @@ -113,57 +511,61 @@ | |
| 511 | assert "decisions" in result |
| 512 | assert any("microservices" in i for i in agent._insights) |
| 513 | assert any("Timeline" in i for i in agent._insights) |
| 514 | |
| 515 | def test_deep_analysis_handles_error(self): |
| 516 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 517 | |
| 518 | pm = MagicMock() |
| 519 | pm.chat.side_effect = Exception("API error") |
| 520 | agent = AgentOrchestrator(provider_manager=pm) |
| 521 | agent._results["transcribe"] = {"text": "some text"} |
| 522 | result = agent._deep_analysis("/tmp") |
| 523 | assert result == {} |
| 524 | |
| 525 | def test_deep_analysis_no_transcript(self): |
| 526 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 527 | |
| 528 | agent = AgentOrchestrator() |
| 529 | agent._results["transcribe"] = {"text": ""} |
| 530 | result = agent._deep_analysis("/tmp") |
| 531 | assert result == {} |
| 532 | |
| 533 | |
| 534 | class TestBuildManifest: |
| 535 | def test_builds_from_results(self): |
| 536 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 537 | |
| 538 | agent = AgentOrchestrator() |
| 539 | agent._results = { |
| 540 | "extract_frames": {"frames": [1, 2, 3], "paths": ["/a.jpg", "/b.jpg"]}, |
| 541 | "extract_audio": {"audio_path": "/audio.wav", "properties": {"duration": 60.0}}, |
| 542 | "detect_diagrams": {"diagrams": [], "captures": []}, |
| 543 | "extract_key_points": {"key_points": []}, |
| 544 | "extract_action_items": {"action_items": []}, |
| 545 | } |
| 546 | manifest = agent._build_manifest(Path("test.mp4"), Path("/out"), "Test", 5.0) |
| 547 | assert manifest.video.title == "Test" |
| 548 | assert manifest.stats.frames_extracted == 3 |
| 549 | assert manifest.stats.duration_seconds == 5.0 |
| 550 | assert manifest.video.duration_seconds == 60.0 |
| 551 | |
| 552 | def test_handles_missing_results(self): |
| 553 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 554 | |
| 555 | agent = AgentOrchestrator() |
| 556 | agent._results = {} |
| 557 | manifest = agent._build_manifest(Path("test.mp4"), Path("/out"), None, 1.0) |
| 558 | assert manifest.video.title == "Analysis of test" |
| 559 | assert manifest.stats.frames_extracted == 0 |
| 560 | |
| 561 | def test_handles_error_results(self): |
| 562 | from video_processor.agent.orchestrator import AgentOrchestrator |
| 563 | |
| 564 | agent = AgentOrchestrator() |
| 565 | agent._results = { |
| 566 | "extract_frames": {"error": "failed"}, |
| 567 | "detect_diagrams": {"error": "also failed"}, |
| 568 | } |
| 569 | manifest = agent._build_manifest(Path("vid.mp4"), Path("/out"), None, 2.0) |
| 570 | assert manifest.stats.frames_extracted == 0 |
| 571 | assert len(manifest.diagrams) == 0 |
| 572 | |
| 573 | DDED tests/test_agent_skills.py |
| 574 | DDED tests/test_api_spec.py |
| 575 | DDED tests/test_auth.py |
| 576 | DDED tests/test_callbacks.py |
| 577 | DDED tests/test_cli.py |
| 578 | DDED tests/test_companion.py |
| 579 | DDED tests/test_exchange.py |
+627
| --- a/tests/test_agent_skills.py | ||
| +++ b/tests/test_agent_skills.py | ||
| @@ -0,0 +1,627 @@ | ||
| 1 | +"""Tests for agent skill execute() methods with mocked context.""" | |
| 2 | + | |
| 3 | +import json | |
| 4 | +from dataclasses import dataclass | |
| 5 | +from unittest.mock import MagicMock | |
| 6 | + | |
| 7 | +import pytest | |
| 8 | + | |
| 9 | +from video_processor.agent.skills.base import ( | |
| 10 | + AgentContext, | |
| 11 | + Artifact, | |
| 12 | + _skills, | |
| 13 | +) | |
| 14 | + | |
| 15 | +# --------------------------------------------------------------------------- | |
| 16 | +# Fixtures | |
| 17 | +# --------------------------------------------------------------------------- | |
| 18 | + | |
| 19 | + | |
| 20 | +@pytest.fixture(autouse=True) | |
| 21 | +def _clean_skill_registry(): | |
| 22 | + """Save and restore the global skill registry between tests.""" | |
| 23 | + original = dict(_skills) | |
| 24 | + yield | |
| 25 | + _skills.clear() | |
| 26 | + _skills.update(original) | |
| 27 | + | |
| 28 | + | |
| 29 | +@dataclass | |
| 30 | +class FakeEntity: | |
| 31 | + name: str | |
| 32 | + type: str | |
| 33 | + | |
| 34 | + def __str__(self): | |
| 35 | + return self.name | |
| 36 | + | |
| 37 | + | |
| 38 | +class FakeQueryResult: | |
| 39 | + """Mimics QueryResult.to_text().""" | |
| 40 | + | |
| 41 | + def __init__(self, text="Stats: 10 entities, 5 relationships"): | |
| 42 | + self._text = text | |
| 43 | + | |
| 44 | + def to_text(self): | |
| 45 | + return self._text | |
| 46 | + | |
| 47 | + | |
| 48 | +def _make_context( | |
| 49 | + chat_response="# Generated Content\n\nSome markdown content.", | |
| 50 | + planning_entities=None, | |
| 51 | +): | |
| 52 | + """Build an AgentContext with mocked query_engine and provider_manager.""" | |
| 53 | + ctx = AgentContext() | |
| 54 | + | |
| 55 | + qe = MagicMock() | |
| 56 | + qe.stats.return_value = FakeQueryResult("Stats: 10 entities, 5 rels") | |
| 57 | + qe.entities.return_value = FakeQueryResult("Entity1, Entity2") | |
| 58 | + qe.relationships.return_value = FakeQueryResult("Entity1 -> Entity2") | |
| 59 | + ctx.query_engine = qe | |
| 60 | + | |
| 61 | + pm = MagicMock() | |
| 62 | + pm.chat.return_value = chat_response | |
| 63 | + ctx.provider_manager = pm | |
| 64 | + | |
| 65 | + ctx.knowledge_graph = MagicMock() | |
| 66 | + | |
| 67 | + if planning_entities is not None: | |
| 68 | + ctx.planning_entities = planning_entities | |
| 69 | + else: | |
| 70 | + ctx.planning_entities = [ | |
| 71 | + FakeEntity(name="Auth system", type="feature"), | |
| 72 | + FakeEntity(name="Launch v1", type="milestone"), | |
| 73 | + FakeEntity(name="Must be fast", type="constraint"), | |
| 74 | + FakeEntity(name="Build dashboard", type="goal"), | |
| 75 | + FakeEntity(name="API depends on auth", type="dependency"), | |
| 76 | + FakeEntity(name="User login", type="requirement"), | |
| 77 | + ] | |
| 78 | + | |
| 79 | + return ctx | |
| 80 | + | |
| 81 | + | |
| 82 | +# --------------------------------------------------------------------------- | |
| 83 | +# ProjectPlanSkill | |
| 84 | +# --------------------------------------------------------------------------- | |
| 85 | + | |
| 86 | + | |
| 87 | +class TestProjectPlanSkill: | |
| 88 | + def test_execute_returns_artifact(self): | |
| 89 | + from video_processor.agent.skills.project_plan import ProjectPlanSkill | |
| 90 | + | |
| 91 | + skill = ProjectPlanSkill() | |
| 92 | + ctx = _make_context() | |
| 93 | + artifact = skill.execute(ctx) | |
| 94 | + | |
| 95 | + assert isinstance(artifact, Artifact) | |
| 96 | + assert artifact.artifact_type == "project_plan" | |
| 97 | + assert artifact.format == "markdown" | |
| 98 | + assert len(artifact.content) > 0 | |
| 99 | + | |
| 100 | + def test_execute_calls_provider(self): | |
| 101 | + from video_processor.agent.skills.project_plan import ProjectPlanSkill | |
| 102 | + | |
| 103 | + skill = ProjectPlanSkill() | |
| 104 | + ctx = _make_context() | |
| 105 | + skill.execute(ctx) | |
| 106 | + | |
| 107 | + ctx.provider_manager.chat.assert_called_once() | |
| 108 | + call_args = ctx.provider_manager.chat.call_args | |
| 109 | + messages = call_args[1]["messages"] if "messages" in call_args[1] else call_args[0][0] | |
| 110 | + assert len(messages) == 1 | |
| 111 | + assert messages[0]["role"] == "user" | |
| 112 | + | |
| 113 | + def test_execute_queries_graph(self): | |
| 114 | + from video_processor.agent.skills.project_plan import ProjectPlanSkill | |
| 115 | + | |
| 116 | + skill = ProjectPlanSkill() | |
| 117 | + ctx = _make_context() | |
| 118 | + skill.execute(ctx) | |
| 119 | + | |
| 120 | + ctx.query_engine.stats.assert_called_once() | |
| 121 | + ctx.query_engine.entities.assert_called_once() | |
| 122 | + ctx.query_engine.relationships.assert_called_once() | |
| 123 | + | |
| 124 | + | |
| 125 | +# --------------------------------------------------------------------------- | |
| 126 | +# PRDSkill | |
| 127 | +# --------------------------------------------------------------------------- | |
| 128 | + | |
| 129 | + | |
| 130 | +class TestPRDSkill: | |
| 131 | + def test_execute_returns_artifact(self): | |
| 132 | + from video_processor.agent.skills.prd import PRDSkill | |
| 133 | + | |
| 134 | + skill = PRDSkill() | |
| 135 | + ctx = _make_context() | |
| 136 | + artifact = skill.execute(ctx) | |
| 137 | + | |
| 138 | + assert isinstance(artifact, Artifact) | |
| 139 | + assert artifact.artifact_type == "prd" | |
| 140 | + assert artifact.format == "markdown" | |
| 141 | + | |
| 142 | + def test_execute_filters_relevant_entities(self): | |
| 143 | + from video_processor.agent.skills.prd import PRDSkill | |
| 144 | + | |
| 145 | + skill = PRDSkill() | |
| 146 | + ctx = _make_context() | |
| 147 | + skill.execute(ctx) | |
| 148 | + | |
| 149 | + # Should still call provider | |
| 150 | + ctx.provider_manager.chat.assert_called_once() | |
| 151 | + | |
| 152 | + def test_execute_with_no_relevant_entities(self): | |
| 153 | + from video_processor.agent.skills.prd import PRDSkill | |
| 154 | + | |
| 155 | + skill = PRDSkill() | |
| 156 | + ctx = _make_context( | |
| 157 | + planning_entities=[ | |
| 158 | + FakeEntity(name="Some goal", type="goal"), | |
| 159 | + ] | |
| 160 | + ) | |
| 161 | + artifact = skill.execute(ctx) | |
| 162 | + | |
| 163 | + assert isinstance(artifact, Artifact) | |
| 164 | + assert artifact.artifact_type == "prd" | |
| 165 | + | |
| 166 | + | |
| 167 | +# --------------------------------------------------------------------------- | |
| 168 | +# RoadmapSkill | |
| 169 | +# --------------------------------------------------------------------------- | |
| 170 | + | |
| 171 | + | |
| 172 | +class TestRoadmapSkill: | |
| 173 | + def test_execute_returns_artifact(self): | |
| 174 | + from video_processor.agent.skills.roadmap import RoadmapSkill | |
| 175 | + | |
| 176 | + skill = RoadmapSkill() | |
| 177 | + ctx = _make_context() | |
| 178 | + artifact = skill.execute(ctx) | |
| 179 | + | |
| 180 | + assert isinstance(artifact, Artifact) | |
| 181 | + assert artifact.artifact_type == "roadmap" | |
| 182 | + assert artifact.format == "markdown" | |
| 183 | + | |
| 184 | + def test_execute_with_no_relevant_entities(self): | |
| 185 | + from video_processor.agent.skills.roadmap import RoadmapSkill | |
| 186 | + | |
| 187 | + skill = RoadmapSkill() | |
| 188 | + ctx = _make_context( | |
| 189 | + planning_entities=[ | |
| 190 | + FakeEntity(name="Some constraint", type="constraint"), | |
| 191 | + ] | |
| 192 | + ) | |
| 193 | + artifact = skill.execute(ctx) | |
| 194 | + | |
| 195 | + assert isinstance(artifact, Artifact) | |
| 196 | + | |
| 197 | + | |
| 198 | +# --------------------------------------------------------------------------- | |
| 199 | +# TaskBreakdownSkill | |
| 200 | +# --------------------------------------------------------------------------- | |
| 201 | + | |
| 202 | + | |
| 203 | +class TestTaskBreakdownSkill: | |
| 204 | + def test_execute_returns_artifact_json(self): | |
| 205 | + from video_processor.agent.skills.task_breakdown import TaskBreakdownSkill | |
| 206 | + | |
| 207 | + tasks_json = json.dumps( | |
| 208 | + [ | |
| 209 | + { | |
| 210 | + "id": "T1", | |
| 211 | + "title": "Setup", | |
| 212 | + "description": "Init", | |
| 213 | + "depends_on": [], | |
| 214 | + "priority": "high", | |
| 215 | + "estimate": "1d", | |
| 216 | + "assignee_role": "dev", | |
| 217 | + }, | |
| 218 | + ] | |
| 219 | + ) | |
| 220 | + skill = TaskBreakdownSkill() | |
| 221 | + ctx = _make_context(chat_response=tasks_json) | |
| 222 | + artifact = skill.execute(ctx) | |
| 223 | + | |
| 224 | + assert isinstance(artifact, Artifact) | |
| 225 | + assert artifact.artifact_type == "task_list" | |
| 226 | + assert artifact.format == "json" | |
| 227 | + assert "tasks" in artifact.metadata | |
| 228 | + assert len(artifact.metadata["tasks"]) == 1 | |
| 229 | + | |
| 230 | + def test_execute_with_non_json_response(self): | |
| 231 | + from video_processor.agent.skills.task_breakdown import TaskBreakdownSkill | |
| 232 | + | |
| 233 | + skill = TaskBreakdownSkill() | |
| 234 | + ctx = _make_context(chat_response="Not valid JSON at all") | |
| 235 | + artifact = skill.execute(ctx) | |
| 236 | + | |
| 237 | + assert isinstance(artifact, Artifact) | |
| 238 | + assert artifact.artifact_type == "task_list" | |
| 239 | + | |
| 240 | + def test_execute_with_no_relevant_entities(self): | |
| 241 | + from video_processor.agent.skills.task_breakdown import TaskBreakdownSkill | |
| 242 | + | |
| 243 | + tasks_json = json.dumps([]) | |
| 244 | + skill = TaskBreakdownSkill() | |
| 245 | + ctx = _make_context( | |
| 246 | + chat_response=tasks_json, | |
| 247 | + planning_entities=[FakeEntity(name="X", type="constraint")], | |
| 248 | + ) | |
| 249 | + artifact = skill.execute(ctx) | |
| 250 | + assert artifact.metadata["tasks"] == [] | |
| 251 | + | |
| 252 | + | |
| 253 | +# --------------------------------------------------------------------------- | |
| 254 | +# DocGeneratorSkill | |
| 255 | +# --------------------------------------------------------------------------- | |
| 256 | + | |
| 257 | + | |
| 258 | +class TestDocGeneratorSkill: | |
| 259 | + def test_execute_default_type(self): | |
| 260 | + from video_processor.agent.skills.doc_generator import DocGeneratorSkill | |
| 261 | + | |
| 262 | + skill = DocGeneratorSkill() | |
| 263 | + ctx = _make_context() | |
| 264 | + artifact = skill.execute(ctx) | |
| 265 | + | |
| 266 | + assert isinstance(artifact, Artifact) | |
| 267 | + assert artifact.artifact_type == "document" | |
| 268 | + assert artifact.format == "markdown" | |
| 269 | + assert artifact.metadata["doc_type"] == "technical_doc" | |
| 270 | + | |
| 271 | + def test_execute_adr_type(self): | |
| 272 | + from video_processor.agent.skills.doc_generator import DocGeneratorSkill | |
| 273 | + | |
| 274 | + skill = DocGeneratorSkill() | |
| 275 | + ctx = _make_context() | |
| 276 | + artifact = skill.execute(ctx, doc_type="adr") | |
| 277 | + | |
| 278 | + assert artifact.metadata["doc_type"] == "adr" | |
| 279 | + | |
| 280 | + def test_execute_meeting_notes_type(self): | |
| 281 | + from video_processor.agent.skills.doc_generator import DocGeneratorSkill | |
| 282 | + | |
| 283 | + skill = DocGeneratorSkill() | |
| 284 | + ctx = _make_context() | |
| 285 | + artifact = skill.execute(ctx, doc_type="meeting_notes") | |
| 286 | + | |
| 287 | + assert artifact.metadata["doc_type"] == "meeting_notes" | |
| 288 | + | |
| 289 | + def test_execute_unknown_type_falls_back(self): | |
| 290 | + from video_processor.agent.skills.doc_generator import DocGeneratorSkill | |
| 291 | + | |
| 292 | + skill = DocGeneratorSkill() | |
| 293 | + ctx = _make_context() | |
| 294 | + artifact = skill.execute(ctx, doc_type="unknown_type") | |
| 295 | + | |
| 296 | + # Falls back to technical_doc prompt | |
| 297 | + assert artifact.artifact_type == "document" | |
| 298 | + | |
| 299 | + | |
| 300 | +# --------------------------------------------------------------------------- | |
| 301 | +# RequirementsChatSkill | |
| 302 | +# --------------------------------------------------------------------------- | |
| 303 | + | |
| 304 | + | |
| 305 | +class TestRequirementsChatSkill: | |
| 306 | + def test_execute_returns_artifact(self): | |
| 307 | + from video_processor.agent.skills.requirements_chat import RequirementsChatSkill | |
| 308 | + | |
| 309 | + questions = { | |
| 310 | + "questions": [ | |
| 311 | + {"id": "Q1", "category": "goals", "question": "What?", "context": "Why"}, | |
| 312 | + ] | |
| 313 | + } | |
| 314 | + skill = RequirementsChatSkill() | |
| 315 | + ctx = _make_context(chat_response=json.dumps(questions)) | |
| 316 | + artifact = skill.execute(ctx) | |
| 317 | + | |
| 318 | + assert isinstance(artifact, Artifact) | |
| 319 | + assert artifact.artifact_type == "requirements" | |
| 320 | + assert artifact.format == "json" | |
| 321 | + assert artifact.metadata["stage"] == "questionnaire" | |
| 322 | + | |
| 323 | + def test_gather_requirements(self): | |
| 324 | + from video_processor.agent.skills.requirements_chat import RequirementsChatSkill | |
| 325 | + | |
| 326 | + reqs = { | |
| 327 | + "goals": ["Build auth"], | |
| 328 | + "constraints": ["Budget < 10k"], | |
| 329 | + "priorities": ["Security"], | |
| 330 | + "scope": {"in_scope": ["Login"], "out_of_scope": ["SSO"]}, | |
| 331 | + } | |
| 332 | + skill = RequirementsChatSkill() | |
| 333 | + ctx = _make_context(chat_response=json.dumps(reqs)) | |
| 334 | + result = skill.gather_requirements(ctx, {"Q1": "We need auth", "Q2": "Budget is limited"}) | |
| 335 | + | |
| 336 | + assert isinstance(result, dict) | |
| 337 | + | |
| 338 | + def test_gather_requirements_non_json_response(self): | |
| 339 | + from video_processor.agent.skills.requirements_chat import RequirementsChatSkill | |
| 340 | + | |
| 341 | + skill = RequirementsChatSkill() | |
| 342 | + ctx = _make_context(chat_response="Not JSON") | |
| 343 | + result = skill.gather_requirements(ctx, {"Q1": "answer"}) | |
| 344 | + | |
| 345 | + assert isinstance(result, dict) | |
| 346 | + | |
| 347 | + | |
| 348 | +# --------------------------------------------------------------------------- | |
| 349 | +# Skill metadata | |
| 350 | +# --------------------------------------------------------------------------- | |
| 351 | + | |
| 352 | + | |
| 353 | +class TestSkillMetadata: | |
| 354 | + def test_project_plan_name(self): | |
| 355 | + from video_processor.agent.skills.project_plan import ProjectPlanSkill | |
| 356 | + | |
| 357 | + assert ProjectPlanSkill.name == "project_plan" | |
| 358 | + | |
| 359 | + def test_prd_name(self): | |
| 360 | + from video_processor.agent.skills.prd import PRDSkill | |
| 361 | + | |
| 362 | + assert PRDSkill.name == "prd" | |
| 363 | + | |
| 364 | + def test_roadmap_name(self): | |
| 365 | + from video_processor.agent.skills.roadmap import RoadmapSkill | |
| 366 | + | |
| 367 | + assert RoadmapSkill.name == "roadmap" | |
| 368 | + | |
| 369 | + def test_task_breakdown_name(self): | |
| 370 | + from video_processor.agent.skills.task_breakdown import TaskBreakdownSkill | |
| 371 | + | |
| 372 | + assert TaskBreakdownSkill.name == "task_breakdown" | |
| 373 | + | |
| 374 | + def test_doc_generator_name(self): | |
| 375 | + from video_processor.agent.skills.doc_generator import DocGeneratorSkill | |
| 376 | + | |
| 377 | + assert DocGeneratorSkill.name == "doc_generator" | |
| 378 | + | |
| 379 | + def test_requirements_chat_name(self): | |
| 380 | + from video_processor.agent.skills.requirements_chat import RequirementsChatSkill | |
| 381 | + | |
| 382 | + assert RequirementsChatSkill.name == "requirements_chat" | |
| 383 | + | |
| 384 | + def test_can_execute_with_context(self): | |
| 385 | + from video_processor.agent.skills.project_plan import ProjectPlanSkill | |
| 386 | + | |
| 387 | + skill = ProjectPlanSkill() | |
| 388 | + ctx = _make_context() | |
| 389 | + assert skill.can_execute(ctx) is True | |
| 390 | + | |
| 391 | + def test_can_execute_without_kg(self): | |
| 392 | + from video_processor.agent.skills.project_plan import ProjectPlanSkill | |
| 393 | + | |
| 394 | + skill = ProjectPlanSkill() | |
| 395 | + ctx = _make_context() | |
| 396 | + ctx.knowledge_graph = None | |
| 397 | + assert skill.can_execute(ctx) is False | |
| 398 | + | |
| 399 | + def test_can_execute_without_provider(self): | |
| 400 | + from video_processor.agent.skills.project_plan import ProjectPlanSkill | |
| 401 | + | |
| 402 | + skill = ProjectPlanSkill() | |
| 403 | + ctx = _make_context() | |
| 404 | + ctx.provider_manager = None | |
| 405 | + assert skill.can_execute(ctx) is False | |
| 406 | + | |
| 407 | + | |
| 408 | +# --------------------------------------------------------------------------- | |
| 409 | +# WikiGeneratorSkill | |
| 410 | +# --------------------------------------------------------------------------- | |
| 411 | + | |
| 412 | + | |
| 413 | +class TestWikiGeneratorSkill: | |
| 414 | + def _sample_kg_data(self): | |
| 415 | + return { | |
| 416 | + "nodes": [ | |
| 417 | + { | |
| 418 | + "name": "Python", | |
| 419 | + "type": "technology", | |
| 420 | + "descriptions": ["A programming language"], | |
| 421 | + }, | |
| 422 | + { | |
| 423 | + "name": "Alice", | |
| 424 | + "type": "person", | |
| 425 | + "descriptions": ["Lead developer"], | |
| 426 | + }, | |
| 427 | + { | |
| 428 | + "name": "FastAPI", | |
| 429 | + "type": "technology", | |
| 430 | + "descriptions": ["Web framework"], | |
| 431 | + }, | |
| 432 | + ], | |
| 433 | + "relationships": [ | |
| 434 | + {"source": "Alice", "target": "Python", "type": "uses"}, | |
| 435 | + {"source": "FastAPI", "target": "Python", "type": "built_with"}, | |
| 436 | + ], | |
| 437 | + } | |
| 438 | + | |
| 439 | + def test_generate_wiki(self): | |
| 440 | + from video_processor.agent.skills.wiki_generator import generate_wiki | |
| 441 | + | |
| 442 | + pages = generate_wiki(self._sample_kg_data(), title="Test Wiki") | |
| 443 | + | |
| 444 | + assert "Home" in pages | |
| 445 | + assert "_Sidebar" in pages | |
| 446 | + assert "Test Wiki" in pages["Home"] | |
| 447 | + assert "3" in pages["Home"] # 3 entities | |
| 448 | + assert "2" in pages["Home"] # 2 relationships | |
| 449 | + | |
| 450 | + # Entity pages should exist | |
| 451 | + assert "Python" in pages | |
| 452 | + assert "Alice" in pages | |
| 453 | + assert "FastAPI" in pages | |
| 454 | + | |
| 455 | + # Type index pages should exist | |
| 456 | + assert "Technology" in pages | |
| 457 | + assert "Person" in pages | |
| 458 | + | |
| 459 | + # Alice's page should reference Python | |
| 460 | + assert "Python" in pages["Alice"] | |
| 461 | + assert "uses" in pages["Alice"] | |
| 462 | + | |
| 463 | + def test_generate_wiki_with_artifacts(self): | |
| 464 | + from video_processor.agent.skills.wiki_generator import generate_wiki | |
| 465 | + | |
| 466 | + art = Artifact( | |
| 467 | + name="Project Plan", | |
| 468 | + content="# Plan\n\nDo the thing.", | |
| 469 | + artifact_type="project_plan", | |
| 470 | + format="markdown", | |
| 471 | + ) | |
| 472 | + pages = generate_wiki(self._sample_kg_data(), artifacts=[art]) | |
| 473 | + | |
| 474 | + assert "Project-Plan" in pages | |
| 475 | + assert "Do the thing." in pages["Project-Plan"] | |
| 476 | + assert "Planning Artifacts" in pages["Home"] | |
| 477 | + | |
| 478 | + def test_write_wiki(self, tmp_path): | |
| 479 | + from video_processor.agent.skills.wiki_generator import write_wiki | |
| 480 | + | |
| 481 | + pages = { | |
| 482 | + "Home": "# Home\n\nWelcome.", | |
| 483 | + "Page-One": "# Page One\n\nContent.", | |
| 484 | + } | |
| 485 | + paths = write_wiki(pages, tmp_path / "wiki") | |
| 486 | + | |
| 487 | + assert len(paths) == 2 | |
| 488 | + assert (tmp_path / "wiki" / "Home.md").exists() | |
| 489 | + assert (tmp_path / "wiki" / "Page-One.md").exists() | |
| 490 | + assert "Welcome." in (tmp_path / "wiki" / "Home.md").read_text() | |
| 491 | + | |
| 492 | + def test_sanitize_filename(self): | |
| 493 | + from video_processor.agent.skills.wiki_generator import _sanitize_filename | |
| 494 | + | |
| 495 | + assert _sanitize_filename("Hello World") == "Hello-World" | |
| 496 | + assert _sanitize_filename("path/to\\file") == "path-to-file" | |
| 497 | + assert _sanitize_filename("version.2") == "version-2" | |
| 498 | + | |
| 499 | + def test_wiki_link(self): | |
| 500 | + from video_processor.agent.skills.wiki_generator import _wiki_link | |
| 501 | + | |
| 502 | + result = _wiki_link("My Page") | |
| 503 | + assert result == "[My Page](My-Page)" | |
| 504 | + | |
| 505 | + result = _wiki_link("Simple") | |
| 506 | + assert result == "[Simple](Simple)" | |
| 507 | + | |
| 508 | + | |
| 509 | +# --------------------------------------------------------------------------- | |
| 510 | +# NotesExportSkill | |
| 511 | +# --------------------------------------------------------------------------- | |
| 512 | + | |
| 513 | + | |
| 514 | +class TestNotesExportSkill: | |
| 515 | + def _sample_kg_data(self): | |
| 516 | + return { | |
| 517 | + "nodes": [ | |
| 518 | + { | |
| 519 | + "name": "Python", | |
| 520 | + "type": "technology", | |
| 521 | + "descriptions": ["A programming language"], | |
| 522 | + }, | |
| 523 | + { | |
| 524 | + "name": "Alice", | |
| 525 | + "type": "person", | |
| 526 | + "descriptions": ["Lead developer"], | |
| 527 | + }, | |
| 528 | + ], | |
| 529 | + "relationships": [ | |
| 530 | + {"source": "Alice", "target": "Python", "type": "uses"}, | |
| 531 | + ], | |
| 532 | + } | |
| 533 | + | |
| 534 | + def test_export_to_obsidian(self, tmp_path): | |
| 535 | + from video_processor.agent.skills.notes_export import export_to_obsidian | |
| 536 | + | |
| 537 | + output_dir = tmp_path / "obsidian_vault" | |
| 538 | + export_to_obsidian(self._sample_kg_data(), output_dir) | |
| 539 | + | |
| 540 | + assert output_dir.is_dir() | |
| 541 | + | |
| 542 | + # Check entity files exist | |
| 543 | + python_file = output_dir / "Python.md" | |
| 544 | + alice_file = output_dir / "Alice.md" | |
| 545 | + assert python_file.exists() | |
| 546 | + assert alice_file.exists() | |
| 547 | + | |
| 548 | + # Check frontmatter in entity file | |
| 549 | + python_content = python_file.read_text() | |
| 550 | + assert "---" in python_content | |
| 551 | + assert "type: technology" in python_content | |
| 552 | + assert "# Python" in python_content | |
| 553 | + | |
| 554 | + # Check wiki-links in Alice file | |
| 555 | + alice_content = alice_file.read_text() | |
| 556 | + assert "[[Python]]" in alice_content | |
| 557 | + assert "uses" in alice_content | |
| 558 | + | |
| 559 | + # Check index file | |
| 560 | + index_file = output_dir / "_Index.md" | |
| 561 | + assert index_file.exists() | |
| 562 | + index_content = index_file.read_text() | |
| 563 | + assert "[[Python]]" in index_content | |
| 564 | + assert "[[Alice]]" in index_content | |
| 565 | + | |
| 566 | + def test_export_to_obsidian_with_artifacts(self, tmp_path): | |
| 567 | + from video_processor.agent.skills.notes_export import export_to_obsidian | |
| 568 | + | |
| 569 | + art = Artifact( | |
| 570 | + name="Test Plan", | |
| 571 | + content="# Plan\n\nSteps here.", | |
| 572 | + artifact_type="project_plan", | |
| 573 | + format="markdown", | |
| 574 | + ) | |
| 575 | + output_dir = tmp_path / "obsidian_arts" | |
| 576 | + export_to_obsidian(self._sample_kg_data(), output_dir, artifacts=[art]) | |
| 577 | + | |
| 578 | + art_file = output_dir / "Test Plan.md" | |
| 579 | + assert art_file.exists() | |
| 580 | + art_content = art_file.read_text() | |
| 581 | + assert "artifact" in art_content | |
| 582 | + assert "Steps here." in art_content | |
| 583 | + | |
| 584 | + def test_export_to_notion_md(self, tmp_path): | |
| 585 | + from video_processor.agent.skills.notes_export import export_to_notion_md | |
| 586 | + | |
| 587 | + output_dir = tmp_path / "notion_export" | |
| 588 | + export_to_notion_md(self._sample_kg_data(), output_dir) | |
| 589 | + | |
| 590 | + assert output_dir.is_dir() | |
| 591 | + | |
| 592 | + # Check CSV database file | |
| 593 | + csv_file = output_dir / "entities_database.csv" | |
| 594 | + assert csv_file.exists() | |
| 595 | + csv_content = csv_file.read_text() | |
| 596 | + assert "Name" in csv_content | |
| 597 | + assert "Type" in csv_content | |
| 598 | + assert "Python" in csv_content | |
| 599 | + assert "Alice" in csv_content | |
| 600 | + | |
| 601 | + # Check entity markdown files | |
| 602 | + python_file = output_dir / "Python.md" | |
| 603 | + assert python_file.exists() | |
| 604 | + python_content = python_file.read_text() | |
| 605 | + assert "# Python" in python_content | |
| 606 | + assert "technology" in python_content | |
| 607 | + | |
| 608 | + # Check overview file | |
| 609 | + overview_file = output_dir / "Overview.md" | |
| 610 | + assert overview_file.exists() | |
| 611 | + | |
| 612 | + def test_export_to_notion_md_with_artifacts(self, tmp_path): | |
| 613 | + from video_processor.agent.skills.notes_export import export_to_notion_md | |
| 614 | + | |
| 615 | + art = Artifact( | |
| 616 | + name="Roadmap", | |
| 617 | + content="# Roadmap\n\nQ1 goals.", | |
| 618 | + artifact_type="roadmap", | |
| 619 | + format="markdown", | |
| 620 | + ) | |
| 621 | + output_dir = tmp_path / "notion_arts" | |
| 622 | + export_to_notion_md(self._sample_kg_data(), output_dir, artifacts=[art]) | |
| 623 | + | |
| 624 | + art_file = output_dir / "Roadmap.md" | |
| 625 | + assert art_file.exists() | |
| 626 | + art_content = art_file.read_text() | |
| 627 | + assert "Q1 goals." in art_content |
| --- a/tests/test_agent_skills.py | |
| +++ b/tests/test_agent_skills.py | |
| @@ -0,0 +1,627 @@ | |
| --- a/tests/test_agent_skills.py | |
| +++ b/tests/test_agent_skills.py | |
| @@ -0,0 +1,627 @@ | |
| 1 | """Tests for agent skill execute() methods with mocked context.""" |
| 2 | |
| 3 | import json |
| 4 | from dataclasses import dataclass |
| 5 | from unittest.mock import MagicMock |
| 6 | |
| 7 | import pytest |
| 8 | |
| 9 | from video_processor.agent.skills.base import ( |
| 10 | AgentContext, |
| 11 | Artifact, |
| 12 | _skills, |
| 13 | ) |
| 14 | |
| 15 | # --------------------------------------------------------------------------- |
| 16 | # Fixtures |
| 17 | # --------------------------------------------------------------------------- |
| 18 | |
| 19 | |
| 20 | @pytest.fixture(autouse=True) |
| 21 | def _clean_skill_registry(): |
| 22 | """Save and restore the global skill registry between tests.""" |
| 23 | original = dict(_skills) |
| 24 | yield |
| 25 | _skills.clear() |
| 26 | _skills.update(original) |
| 27 | |
| 28 | |
| 29 | @dataclass |
| 30 | class FakeEntity: |
| 31 | name: str |
| 32 | type: str |
| 33 | |
| 34 | def __str__(self): |
| 35 | return self.name |
| 36 | |
| 37 | |
| 38 | class FakeQueryResult: |
| 39 | """Mimics QueryResult.to_text().""" |
| 40 | |
| 41 | def __init__(self, text="Stats: 10 entities, 5 relationships"): |
| 42 | self._text = text |
| 43 | |
| 44 | def to_text(self): |
| 45 | return self._text |
| 46 | |
| 47 | |
| 48 | def _make_context( |
| 49 | chat_response="# Generated Content\n\nSome markdown content.", |
| 50 | planning_entities=None, |
| 51 | ): |
| 52 | """Build an AgentContext with mocked query_engine and provider_manager.""" |
| 53 | ctx = AgentContext() |
| 54 | |
| 55 | qe = MagicMock() |
| 56 | qe.stats.return_value = FakeQueryResult("Stats: 10 entities, 5 rels") |
| 57 | qe.entities.return_value = FakeQueryResult("Entity1, Entity2") |
| 58 | qe.relationships.return_value = FakeQueryResult("Entity1 -> Entity2") |
| 59 | ctx.query_engine = qe |
| 60 | |
| 61 | pm = MagicMock() |
| 62 | pm.chat.return_value = chat_response |
| 63 | ctx.provider_manager = pm |
| 64 | |
| 65 | ctx.knowledge_graph = MagicMock() |
| 66 | |
| 67 | if planning_entities is not None: |
| 68 | ctx.planning_entities = planning_entities |
| 69 | else: |
| 70 | ctx.planning_entities = [ |
| 71 | FakeEntity(name="Auth system", type="feature"), |
| 72 | FakeEntity(name="Launch v1", type="milestone"), |
| 73 | FakeEntity(name="Must be fast", type="constraint"), |
| 74 | FakeEntity(name="Build dashboard", type="goal"), |
| 75 | FakeEntity(name="API depends on auth", type="dependency"), |
| 76 | FakeEntity(name="User login", type="requirement"), |
| 77 | ] |
| 78 | |
| 79 | return ctx |
| 80 | |
| 81 | |
| 82 | # --------------------------------------------------------------------------- |
| 83 | # ProjectPlanSkill |
| 84 | # --------------------------------------------------------------------------- |
| 85 | |
| 86 | |
| 87 | class TestProjectPlanSkill: |
| 88 | def test_execute_returns_artifact(self): |
| 89 | from video_processor.agent.skills.project_plan import ProjectPlanSkill |
| 90 | |
| 91 | skill = ProjectPlanSkill() |
| 92 | ctx = _make_context() |
| 93 | artifact = skill.execute(ctx) |
| 94 | |
| 95 | assert isinstance(artifact, Artifact) |
| 96 | assert artifact.artifact_type == "project_plan" |
| 97 | assert artifact.format == "markdown" |
| 98 | assert len(artifact.content) > 0 |
| 99 | |
| 100 | def test_execute_calls_provider(self): |
| 101 | from video_processor.agent.skills.project_plan import ProjectPlanSkill |
| 102 | |
| 103 | skill = ProjectPlanSkill() |
| 104 | ctx = _make_context() |
| 105 | skill.execute(ctx) |
| 106 | |
| 107 | ctx.provider_manager.chat.assert_called_once() |
| 108 | call_args = ctx.provider_manager.chat.call_args |
| 109 | messages = call_args[1]["messages"] if "messages" in call_args[1] else call_args[0][0] |
| 110 | assert len(messages) == 1 |
| 111 | assert messages[0]["role"] == "user" |
| 112 | |
| 113 | def test_execute_queries_graph(self): |
| 114 | from video_processor.agent.skills.project_plan import ProjectPlanSkill |
| 115 | |
| 116 | skill = ProjectPlanSkill() |
| 117 | ctx = _make_context() |
| 118 | skill.execute(ctx) |
| 119 | |
| 120 | ctx.query_engine.stats.assert_called_once() |
| 121 | ctx.query_engine.entities.assert_called_once() |
| 122 | ctx.query_engine.relationships.assert_called_once() |
| 123 | |
| 124 | |
| 125 | # --------------------------------------------------------------------------- |
| 126 | # PRDSkill |
| 127 | # --------------------------------------------------------------------------- |
| 128 | |
| 129 | |
| 130 | class TestPRDSkill: |
| 131 | def test_execute_returns_artifact(self): |
| 132 | from video_processor.agent.skills.prd import PRDSkill |
| 133 | |
| 134 | skill = PRDSkill() |
| 135 | ctx = _make_context() |
| 136 | artifact = skill.execute(ctx) |
| 137 | |
| 138 | assert isinstance(artifact, Artifact) |
| 139 | assert artifact.artifact_type == "prd" |
| 140 | assert artifact.format == "markdown" |
| 141 | |
| 142 | def test_execute_filters_relevant_entities(self): |
| 143 | from video_processor.agent.skills.prd import PRDSkill |
| 144 | |
| 145 | skill = PRDSkill() |
| 146 | ctx = _make_context() |
| 147 | skill.execute(ctx) |
| 148 | |
| 149 | # Should still call provider |
| 150 | ctx.provider_manager.chat.assert_called_once() |
| 151 | |
| 152 | def test_execute_with_no_relevant_entities(self): |
| 153 | from video_processor.agent.skills.prd import PRDSkill |
| 154 | |
| 155 | skill = PRDSkill() |
| 156 | ctx = _make_context( |
| 157 | planning_entities=[ |
| 158 | FakeEntity(name="Some goal", type="goal"), |
| 159 | ] |
| 160 | ) |
| 161 | artifact = skill.execute(ctx) |
| 162 | |
| 163 | assert isinstance(artifact, Artifact) |
| 164 | assert artifact.artifact_type == "prd" |
| 165 | |
| 166 | |
| 167 | # --------------------------------------------------------------------------- |
| 168 | # RoadmapSkill |
| 169 | # --------------------------------------------------------------------------- |
| 170 | |
| 171 | |
| 172 | class TestRoadmapSkill: |
| 173 | def test_execute_returns_artifact(self): |
| 174 | from video_processor.agent.skills.roadmap import RoadmapSkill |
| 175 | |
| 176 | skill = RoadmapSkill() |
| 177 | ctx = _make_context() |
| 178 | artifact = skill.execute(ctx) |
| 179 | |
| 180 | assert isinstance(artifact, Artifact) |
| 181 | assert artifact.artifact_type == "roadmap" |
| 182 | assert artifact.format == "markdown" |
| 183 | |
| 184 | def test_execute_with_no_relevant_entities(self): |
| 185 | from video_processor.agent.skills.roadmap import RoadmapSkill |
| 186 | |
| 187 | skill = RoadmapSkill() |
| 188 | ctx = _make_context( |
| 189 | planning_entities=[ |
| 190 | FakeEntity(name="Some constraint", type="constraint"), |
| 191 | ] |
| 192 | ) |
| 193 | artifact = skill.execute(ctx) |
| 194 | |
| 195 | assert isinstance(artifact, Artifact) |
| 196 | |
| 197 | |
| 198 | # --------------------------------------------------------------------------- |
| 199 | # TaskBreakdownSkill |
| 200 | # --------------------------------------------------------------------------- |
| 201 | |
| 202 | |
| 203 | class TestTaskBreakdownSkill: |
| 204 | def test_execute_returns_artifact_json(self): |
| 205 | from video_processor.agent.skills.task_breakdown import TaskBreakdownSkill |
| 206 | |
| 207 | tasks_json = json.dumps( |
| 208 | [ |
| 209 | { |
| 210 | "id": "T1", |
| 211 | "title": "Setup", |
| 212 | "description": "Init", |
| 213 | "depends_on": [], |
| 214 | "priority": "high", |
| 215 | "estimate": "1d", |
| 216 | "assignee_role": "dev", |
| 217 | }, |
| 218 | ] |
| 219 | ) |
| 220 | skill = TaskBreakdownSkill() |
| 221 | ctx = _make_context(chat_response=tasks_json) |
| 222 | artifact = skill.execute(ctx) |
| 223 | |
| 224 | assert isinstance(artifact, Artifact) |
| 225 | assert artifact.artifact_type == "task_list" |
| 226 | assert artifact.format == "json" |
| 227 | assert "tasks" in artifact.metadata |
| 228 | assert len(artifact.metadata["tasks"]) == 1 |
| 229 | |
| 230 | def test_execute_with_non_json_response(self): |
| 231 | from video_processor.agent.skills.task_breakdown import TaskBreakdownSkill |
| 232 | |
| 233 | skill = TaskBreakdownSkill() |
| 234 | ctx = _make_context(chat_response="Not valid JSON at all") |
| 235 | artifact = skill.execute(ctx) |
| 236 | |
| 237 | assert isinstance(artifact, Artifact) |
| 238 | assert artifact.artifact_type == "task_list" |
| 239 | |
| 240 | def test_execute_with_no_relevant_entities(self): |
| 241 | from video_processor.agent.skills.task_breakdown import TaskBreakdownSkill |
| 242 | |
| 243 | tasks_json = json.dumps([]) |
| 244 | skill = TaskBreakdownSkill() |
| 245 | ctx = _make_context( |
| 246 | chat_response=tasks_json, |
| 247 | planning_entities=[FakeEntity(name="X", type="constraint")], |
| 248 | ) |
| 249 | artifact = skill.execute(ctx) |
| 250 | assert artifact.metadata["tasks"] == [] |
| 251 | |
| 252 | |
| 253 | # --------------------------------------------------------------------------- |
| 254 | # DocGeneratorSkill |
| 255 | # --------------------------------------------------------------------------- |
| 256 | |
| 257 | |
| 258 | class TestDocGeneratorSkill: |
| 259 | def test_execute_default_type(self): |
| 260 | from video_processor.agent.skills.doc_generator import DocGeneratorSkill |
| 261 | |
| 262 | skill = DocGeneratorSkill() |
| 263 | ctx = _make_context() |
| 264 | artifact = skill.execute(ctx) |
| 265 | |
| 266 | assert isinstance(artifact, Artifact) |
| 267 | assert artifact.artifact_type == "document" |
| 268 | assert artifact.format == "markdown" |
| 269 | assert artifact.metadata["doc_type"] == "technical_doc" |
| 270 | |
| 271 | def test_execute_adr_type(self): |
| 272 | from video_processor.agent.skills.doc_generator import DocGeneratorSkill |
| 273 | |
| 274 | skill = DocGeneratorSkill() |
| 275 | ctx = _make_context() |
| 276 | artifact = skill.execute(ctx, doc_type="adr") |
| 277 | |
| 278 | assert artifact.metadata["doc_type"] == "adr" |
| 279 | |
| 280 | def test_execute_meeting_notes_type(self): |
| 281 | from video_processor.agent.skills.doc_generator import DocGeneratorSkill |
| 282 | |
| 283 | skill = DocGeneratorSkill() |
| 284 | ctx = _make_context() |
| 285 | artifact = skill.execute(ctx, doc_type="meeting_notes") |
| 286 | |
| 287 | assert artifact.metadata["doc_type"] == "meeting_notes" |
| 288 | |
| 289 | def test_execute_unknown_type_falls_back(self): |
| 290 | from video_processor.agent.skills.doc_generator import DocGeneratorSkill |
| 291 | |
| 292 | skill = DocGeneratorSkill() |
| 293 | ctx = _make_context() |
| 294 | artifact = skill.execute(ctx, doc_type="unknown_type") |
| 295 | |
| 296 | # Falls back to technical_doc prompt |
| 297 | assert artifact.artifact_type == "document" |
| 298 | |
| 299 | |
| 300 | # --------------------------------------------------------------------------- |
| 301 | # RequirementsChatSkill |
| 302 | # --------------------------------------------------------------------------- |
| 303 | |
| 304 | |
| 305 | class TestRequirementsChatSkill: |
| 306 | def test_execute_returns_artifact(self): |
| 307 | from video_processor.agent.skills.requirements_chat import RequirementsChatSkill |
| 308 | |
| 309 | questions = { |
| 310 | "questions": [ |
| 311 | {"id": "Q1", "category": "goals", "question": "What?", "context": "Why"}, |
| 312 | ] |
| 313 | } |
| 314 | skill = RequirementsChatSkill() |
| 315 | ctx = _make_context(chat_response=json.dumps(questions)) |
| 316 | artifact = skill.execute(ctx) |
| 317 | |
| 318 | assert isinstance(artifact, Artifact) |
| 319 | assert artifact.artifact_type == "requirements" |
| 320 | assert artifact.format == "json" |
| 321 | assert artifact.metadata["stage"] == "questionnaire" |
| 322 | |
| 323 | def test_gather_requirements(self): |
| 324 | from video_processor.agent.skills.requirements_chat import RequirementsChatSkill |
| 325 | |
| 326 | reqs = { |
| 327 | "goals": ["Build auth"], |
| 328 | "constraints": ["Budget < 10k"], |
| 329 | "priorities": ["Security"], |
| 330 | "scope": {"in_scope": ["Login"], "out_of_scope": ["SSO"]}, |
| 331 | } |
| 332 | skill = RequirementsChatSkill() |
| 333 | ctx = _make_context(chat_response=json.dumps(reqs)) |
| 334 | result = skill.gather_requirements(ctx, {"Q1": "We need auth", "Q2": "Budget is limited"}) |
| 335 | |
| 336 | assert isinstance(result, dict) |
| 337 | |
| 338 | def test_gather_requirements_non_json_response(self): |
| 339 | from video_processor.agent.skills.requirements_chat import RequirementsChatSkill |
| 340 | |
| 341 | skill = RequirementsChatSkill() |
| 342 | ctx = _make_context(chat_response="Not JSON") |
| 343 | result = skill.gather_requirements(ctx, {"Q1": "answer"}) |
| 344 | |
| 345 | assert isinstance(result, dict) |
| 346 | |
| 347 | |
| 348 | # --------------------------------------------------------------------------- |
| 349 | # Skill metadata |
| 350 | # --------------------------------------------------------------------------- |
| 351 | |
| 352 | |
| 353 | class TestSkillMetadata: |
| 354 | def test_project_plan_name(self): |
| 355 | from video_processor.agent.skills.project_plan import ProjectPlanSkill |
| 356 | |
| 357 | assert ProjectPlanSkill.name == "project_plan" |
| 358 | |
| 359 | def test_prd_name(self): |
| 360 | from video_processor.agent.skills.prd import PRDSkill |
| 361 | |
| 362 | assert PRDSkill.name == "prd" |
| 363 | |
| 364 | def test_roadmap_name(self): |
| 365 | from video_processor.agent.skills.roadmap import RoadmapSkill |
| 366 | |
| 367 | assert RoadmapSkill.name == "roadmap" |
| 368 | |
| 369 | def test_task_breakdown_name(self): |
| 370 | from video_processor.agent.skills.task_breakdown import TaskBreakdownSkill |
| 371 | |
| 372 | assert TaskBreakdownSkill.name == "task_breakdown" |
| 373 | |
| 374 | def test_doc_generator_name(self): |
| 375 | from video_processor.agent.skills.doc_generator import DocGeneratorSkill |
| 376 | |
| 377 | assert DocGeneratorSkill.name == "doc_generator" |
| 378 | |
| 379 | def test_requirements_chat_name(self): |
| 380 | from video_processor.agent.skills.requirements_chat import RequirementsChatSkill |
| 381 | |
| 382 | assert RequirementsChatSkill.name == "requirements_chat" |
| 383 | |
| 384 | def test_can_execute_with_context(self): |
| 385 | from video_processor.agent.skills.project_plan import ProjectPlanSkill |
| 386 | |
| 387 | skill = ProjectPlanSkill() |
| 388 | ctx = _make_context() |
| 389 | assert skill.can_execute(ctx) is True |
| 390 | |
| 391 | def test_can_execute_without_kg(self): |
| 392 | from video_processor.agent.skills.project_plan import ProjectPlanSkill |
| 393 | |
| 394 | skill = ProjectPlanSkill() |
| 395 | ctx = _make_context() |
| 396 | ctx.knowledge_graph = None |
| 397 | assert skill.can_execute(ctx) is False |
| 398 | |
| 399 | def test_can_execute_without_provider(self): |
| 400 | from video_processor.agent.skills.project_plan import ProjectPlanSkill |
| 401 | |
| 402 | skill = ProjectPlanSkill() |
| 403 | ctx = _make_context() |
| 404 | ctx.provider_manager = None |
| 405 | assert skill.can_execute(ctx) is False |
| 406 | |
| 407 | |
| 408 | # --------------------------------------------------------------------------- |
| 409 | # WikiGeneratorSkill |
| 410 | # --------------------------------------------------------------------------- |
| 411 | |
| 412 | |
| 413 | class TestWikiGeneratorSkill: |
| 414 | def _sample_kg_data(self): |
| 415 | return { |
| 416 | "nodes": [ |
| 417 | { |
| 418 | "name": "Python", |
| 419 | "type": "technology", |
| 420 | "descriptions": ["A programming language"], |
| 421 | }, |
| 422 | { |
| 423 | "name": "Alice", |
| 424 | "type": "person", |
| 425 | "descriptions": ["Lead developer"], |
| 426 | }, |
| 427 | { |
| 428 | "name": "FastAPI", |
| 429 | "type": "technology", |
| 430 | "descriptions": ["Web framework"], |
| 431 | }, |
| 432 | ], |
| 433 | "relationships": [ |
| 434 | {"source": "Alice", "target": "Python", "type": "uses"}, |
| 435 | {"source": "FastAPI", "target": "Python", "type": "built_with"}, |
| 436 | ], |
| 437 | } |
| 438 | |
| 439 | def test_generate_wiki(self): |
| 440 | from video_processor.agent.skills.wiki_generator import generate_wiki |
| 441 | |
| 442 | pages = generate_wiki(self._sample_kg_data(), title="Test Wiki") |
| 443 | |
| 444 | assert "Home" in pages |
| 445 | assert "_Sidebar" in pages |
| 446 | assert "Test Wiki" in pages["Home"] |
| 447 | assert "3" in pages["Home"] # 3 entities |
| 448 | assert "2" in pages["Home"] # 2 relationships |
| 449 | |
| 450 | # Entity pages should exist |
| 451 | assert "Python" in pages |
| 452 | assert "Alice" in pages |
| 453 | assert "FastAPI" in pages |
| 454 | |
| 455 | # Type index pages should exist |
| 456 | assert "Technology" in pages |
| 457 | assert "Person" in pages |
| 458 | |
| 459 | # Alice's page should reference Python |
| 460 | assert "Python" in pages["Alice"] |
| 461 | assert "uses" in pages["Alice"] |
| 462 | |
| 463 | def test_generate_wiki_with_artifacts(self): |
| 464 | from video_processor.agent.skills.wiki_generator import generate_wiki |
| 465 | |
| 466 | art = Artifact( |
| 467 | name="Project Plan", |
| 468 | content="# Plan\n\nDo the thing.", |
| 469 | artifact_type="project_plan", |
| 470 | format="markdown", |
| 471 | ) |
| 472 | pages = generate_wiki(self._sample_kg_data(), artifacts=[art]) |
| 473 | |
| 474 | assert "Project-Plan" in pages |
| 475 | assert "Do the thing." in pages["Project-Plan"] |
| 476 | assert "Planning Artifacts" in pages["Home"] |
| 477 | |
| 478 | def test_write_wiki(self, tmp_path): |
| 479 | from video_processor.agent.skills.wiki_generator import write_wiki |
| 480 | |
| 481 | pages = { |
| 482 | "Home": "# Home\n\nWelcome.", |
| 483 | "Page-One": "# Page One\n\nContent.", |
| 484 | } |
| 485 | paths = write_wiki(pages, tmp_path / "wiki") |
| 486 | |
| 487 | assert len(paths) == 2 |
| 488 | assert (tmp_path / "wiki" / "Home.md").exists() |
| 489 | assert (tmp_path / "wiki" / "Page-One.md").exists() |
| 490 | assert "Welcome." in (tmp_path / "wiki" / "Home.md").read_text() |
| 491 | |
| 492 | def test_sanitize_filename(self): |
| 493 | from video_processor.agent.skills.wiki_generator import _sanitize_filename |
| 494 | |
| 495 | assert _sanitize_filename("Hello World") == "Hello-World" |
| 496 | assert _sanitize_filename("path/to\\file") == "path-to-file" |
| 497 | assert _sanitize_filename("version.2") == "version-2" |
| 498 | |
| 499 | def test_wiki_link(self): |
| 500 | from video_processor.agent.skills.wiki_generator import _wiki_link |
| 501 | |
| 502 | result = _wiki_link("My Page") |
| 503 | assert result == "[My Page](My-Page)" |
| 504 | |
| 505 | result = _wiki_link("Simple") |
| 506 | assert result == "[Simple](Simple)" |
| 507 | |
| 508 | |
| 509 | # --------------------------------------------------------------------------- |
| 510 | # NotesExportSkill |
| 511 | # --------------------------------------------------------------------------- |
| 512 | |
| 513 | |
| 514 | class TestNotesExportSkill: |
| 515 | def _sample_kg_data(self): |
| 516 | return { |
| 517 | "nodes": [ |
| 518 | { |
| 519 | "name": "Python", |
| 520 | "type": "technology", |
| 521 | "descriptions": ["A programming language"], |
| 522 | }, |
| 523 | { |
| 524 | "name": "Alice", |
| 525 | "type": "person", |
| 526 | "descriptions": ["Lead developer"], |
| 527 | }, |
| 528 | ], |
| 529 | "relationships": [ |
| 530 | {"source": "Alice", "target": "Python", "type": "uses"}, |
| 531 | ], |
| 532 | } |
| 533 | |
| 534 | def test_export_to_obsidian(self, tmp_path): |
| 535 | from video_processor.agent.skills.notes_export import export_to_obsidian |
| 536 | |
| 537 | output_dir = tmp_path / "obsidian_vault" |
| 538 | export_to_obsidian(self._sample_kg_data(), output_dir) |
| 539 | |
| 540 | assert output_dir.is_dir() |
| 541 | |
| 542 | # Check entity files exist |
| 543 | python_file = output_dir / "Python.md" |
| 544 | alice_file = output_dir / "Alice.md" |
| 545 | assert python_file.exists() |
| 546 | assert alice_file.exists() |
| 547 | |
| 548 | # Check frontmatter in entity file |
| 549 | python_content = python_file.read_text() |
| 550 | assert "---" in python_content |
| 551 | assert "type: technology" in python_content |
| 552 | assert "# Python" in python_content |
| 553 | |
| 554 | # Check wiki-links in Alice file |
| 555 | alice_content = alice_file.read_text() |
| 556 | assert "[[Python]]" in alice_content |
| 557 | assert "uses" in alice_content |
| 558 | |
| 559 | # Check index file |
| 560 | index_file = output_dir / "_Index.md" |
| 561 | assert index_file.exists() |
| 562 | index_content = index_file.read_text() |
| 563 | assert "[[Python]]" in index_content |
| 564 | assert "[[Alice]]" in index_content |
| 565 | |
| 566 | def test_export_to_obsidian_with_artifacts(self, tmp_path): |
| 567 | from video_processor.agent.skills.notes_export import export_to_obsidian |
| 568 | |
| 569 | art = Artifact( |
| 570 | name="Test Plan", |
| 571 | content="# Plan\n\nSteps here.", |
| 572 | artifact_type="project_plan", |
| 573 | format="markdown", |
| 574 | ) |
| 575 | output_dir = tmp_path / "obsidian_arts" |
| 576 | export_to_obsidian(self._sample_kg_data(), output_dir, artifacts=[art]) |
| 577 | |
| 578 | art_file = output_dir / "Test Plan.md" |
| 579 | assert art_file.exists() |
| 580 | art_content = art_file.read_text() |
| 581 | assert "artifact" in art_content |
| 582 | assert "Steps here." in art_content |
| 583 | |
| 584 | def test_export_to_notion_md(self, tmp_path): |
| 585 | from video_processor.agent.skills.notes_export import export_to_notion_md |
| 586 | |
| 587 | output_dir = tmp_path / "notion_export" |
| 588 | export_to_notion_md(self._sample_kg_data(), output_dir) |
| 589 | |
| 590 | assert output_dir.is_dir() |
| 591 | |
| 592 | # Check CSV database file |
| 593 | csv_file = output_dir / "entities_database.csv" |
| 594 | assert csv_file.exists() |
| 595 | csv_content = csv_file.read_text() |
| 596 | assert "Name" in csv_content |
| 597 | assert "Type" in csv_content |
| 598 | assert "Python" in csv_content |
| 599 | assert "Alice" in csv_content |
| 600 | |
| 601 | # Check entity markdown files |
| 602 | python_file = output_dir / "Python.md" |
| 603 | assert python_file.exists() |
| 604 | python_content = python_file.read_text() |
| 605 | assert "# Python" in python_content |
| 606 | assert "technology" in python_content |
| 607 | |
| 608 | # Check overview file |
| 609 | overview_file = output_dir / "Overview.md" |
| 610 | assert overview_file.exists() |
| 611 | |
| 612 | def test_export_to_notion_md_with_artifacts(self, tmp_path): |
| 613 | from video_processor.agent.skills.notes_export import export_to_notion_md |
| 614 | |
| 615 | art = Artifact( |
| 616 | name="Roadmap", |
| 617 | content="# Roadmap\n\nQ1 goals.", |
| 618 | artifact_type="roadmap", |
| 619 | format="markdown", |
| 620 | ) |
| 621 | output_dir = tmp_path / "notion_arts" |
| 622 | export_to_notion_md(self._sample_kg_data(), output_dir, artifacts=[art]) |
| 623 | |
| 624 | art_file = output_dir / "Roadmap.md" |
| 625 | assert art_file.exists() |
| 626 | art_content = art_file.read_text() |
| 627 | assert "Q1 goals." in art_content |
+114
| --- a/tests/test_api_spec.py | ||
| +++ b/tests/test_api_spec.py | ||
| @@ -0,0 +1,114 @@ | ||
| 1 | +"""Tests for video_processor.api.openapi_spec.""" | |
| 2 | + | |
| 3 | +from video_processor.api.openapi_spec import get_openapi_spec | |
| 4 | + | |
| 5 | + | |
| 6 | +def test_returns_dict(): | |
| 7 | + spec = get_openapi_spec() | |
| 8 | + assert isinstance(spec, dict) | |
| 9 | + | |
| 10 | + | |
| 11 | +def test_has_top_level_keys(): | |
| 12 | + spec = get_openapi_spec() | |
| 13 | + for key in ("openapi", "info", "paths", "components"): | |
| 14 | + assert key in spec, f"Missing top-level key: {key}" | |
| 15 | + | |
| 16 | + | |
| 17 | +def test_openapi_version(): | |
| 18 | + spec = get_openapi_spec() | |
| 19 | + assert spec["openapi"].startswith("3.0") | |
| 20 | + | |
| 21 | + | |
| 22 | +def test_info_section(): | |
| 23 | + spec = get_openapi_spec() | |
| 24 | + info = spec["info"] | |
| 25 | + assert "title" in info | |
| 26 | + assert "version" in info | |
| 27 | + assert "PlanOpticon" in info["title"] | |
| 28 | + | |
| 29 | + | |
| 30 | +def test_expected_paths(): | |
| 31 | + spec = get_openapi_spec() | |
| 32 | + expected_paths = [ | |
| 33 | + "/analyze", | |
| 34 | + "/jobs/{id}", | |
| 35 | + "/knowledge-graph/{id}/entities", | |
| 36 | + "/knowledge-graph/{id}/relationships", | |
| 37 | + "/knowledge-graph/{id}/query", | |
| 38 | + ] | |
| 39 | + for path in expected_paths: | |
| 40 | + assert path in spec["paths"], f"Missing path: {path}" | |
| 41 | + | |
| 42 | + | |
| 43 | +def test_analyze_endpoint(): | |
| 44 | + spec = get_openapi_spec() | |
| 45 | + analyze = spec["paths"]["/analyze"] | |
| 46 | + assert "post" in analyze | |
| 47 | + post = analyze["post"] | |
| 48 | + assert "summary" in post | |
| 49 | + assert "requestBody" in post | |
| 50 | + assert "responses" in post | |
| 51 | + assert "202" in post["responses"] | |
| 52 | + | |
| 53 | + | |
| 54 | +def test_jobs_endpoint(): | |
| 55 | + spec = get_openapi_spec() | |
| 56 | + jobs = spec["paths"]["/jobs/{id}"] | |
| 57 | + assert "get" in jobs | |
| 58 | + get = jobs["get"] | |
| 59 | + assert "parameters" in get | |
| 60 | + assert get["parameters"][0]["name"] == "id" | |
| 61 | + | |
| 62 | + | |
| 63 | +def test_entities_endpoint(): | |
| 64 | + spec = get_openapi_spec() | |
| 65 | + entities = spec["paths"]["/knowledge-graph/{id}/entities"] | |
| 66 | + assert "get" in entities | |
| 67 | + | |
| 68 | + | |
| 69 | +def test_relationships_endpoint(): | |
| 70 | + spec = get_openapi_spec() | |
| 71 | + rels = spec["paths"]["/knowledge-graph/{id}/relationships"] | |
| 72 | + assert "get" in rels | |
| 73 | + | |
| 74 | + | |
| 75 | +def test_query_endpoint(): | |
| 76 | + spec = get_openapi_spec() | |
| 77 | + query = spec["paths"]["/knowledge-graph/{id}/query"] | |
| 78 | + assert "get" in query | |
| 79 | + params = query["get"]["parameters"] | |
| 80 | + param_names = [p["name"] for p in params] | |
| 81 | + assert "q" in param_names | |
| 82 | + | |
| 83 | + | |
| 84 | +def test_component_schemas(): | |
| 85 | + spec = get_openapi_spec() | |
| 86 | + schemas = spec["components"]["schemas"] | |
| 87 | + for schema_name in ("Job", "Entity", "Relationship"): | |
| 88 | + assert schema_name in schemas, f"Missing schema: {schema_name}" | |
| 89 | + | |
| 90 | + | |
| 91 | +def test_job_schema_properties(): | |
| 92 | + spec = get_openapi_spec() | |
| 93 | + job = spec["components"]["schemas"]["Job"] | |
| 94 | + props = job["properties"] | |
| 95 | + assert "id" in props | |
| 96 | + assert "status" in props | |
| 97 | + assert "progress" in props | |
| 98 | + | |
| 99 | + | |
| 100 | +def test_job_status_enum(): | |
| 101 | + spec = get_openapi_spec() | |
| 102 | + status = spec["components"]["schemas"]["Job"]["properties"]["status"] | |
| 103 | + assert "enum" in status | |
| 104 | + assert "pending" in status["enum"] | |
| 105 | + assert "completed" in status["enum"] | |
| 106 | + | |
| 107 | + | |
| 108 | +def test_analyze_request_body_schema(): | |
| 109 | + spec = get_openapi_spec() | |
| 110 | + schema = spec["paths"]["/analyze"]["post"]["requestBody"]["content"]["application/json"][ | |
| 111 | + "schema" | |
| 112 | + ] | |
| 113 | + assert "video_url" in schema["properties"] | |
| 114 | + assert "video_url" in schema["required"] |
| --- a/tests/test_api_spec.py | |
| +++ b/tests/test_api_spec.py | |
| @@ -0,0 +1,114 @@ | |
| --- a/tests/test_api_spec.py | |
| +++ b/tests/test_api_spec.py | |
| @@ -0,0 +1,114 @@ | |
| 1 | """Tests for video_processor.api.openapi_spec.""" |
| 2 | |
| 3 | from video_processor.api.openapi_spec import get_openapi_spec |
| 4 | |
| 5 | |
| 6 | def test_returns_dict(): |
| 7 | spec = get_openapi_spec() |
| 8 | assert isinstance(spec, dict) |
| 9 | |
| 10 | |
| 11 | def test_has_top_level_keys(): |
| 12 | spec = get_openapi_spec() |
| 13 | for key in ("openapi", "info", "paths", "components"): |
| 14 | assert key in spec, f"Missing top-level key: {key}" |
| 15 | |
| 16 | |
| 17 | def test_openapi_version(): |
| 18 | spec = get_openapi_spec() |
| 19 | assert spec["openapi"].startswith("3.0") |
| 20 | |
| 21 | |
| 22 | def test_info_section(): |
| 23 | spec = get_openapi_spec() |
| 24 | info = spec["info"] |
| 25 | assert "title" in info |
| 26 | assert "version" in info |
| 27 | assert "PlanOpticon" in info["title"] |
| 28 | |
| 29 | |
| 30 | def test_expected_paths(): |
| 31 | spec = get_openapi_spec() |
| 32 | expected_paths = [ |
| 33 | "/analyze", |
| 34 | "/jobs/{id}", |
| 35 | "/knowledge-graph/{id}/entities", |
| 36 | "/knowledge-graph/{id}/relationships", |
| 37 | "/knowledge-graph/{id}/query", |
| 38 | ] |
| 39 | for path in expected_paths: |
| 40 | assert path in spec["paths"], f"Missing path: {path}" |
| 41 | |
| 42 | |
| 43 | def test_analyze_endpoint(): |
| 44 | spec = get_openapi_spec() |
| 45 | analyze = spec["paths"]["/analyze"] |
| 46 | assert "post" in analyze |
| 47 | post = analyze["post"] |
| 48 | assert "summary" in post |
| 49 | assert "requestBody" in post |
| 50 | assert "responses" in post |
| 51 | assert "202" in post["responses"] |
| 52 | |
| 53 | |
| 54 | def test_jobs_endpoint(): |
| 55 | spec = get_openapi_spec() |
| 56 | jobs = spec["paths"]["/jobs/{id}"] |
| 57 | assert "get" in jobs |
| 58 | get = jobs["get"] |
| 59 | assert "parameters" in get |
| 60 | assert get["parameters"][0]["name"] == "id" |
| 61 | |
| 62 | |
| 63 | def test_entities_endpoint(): |
| 64 | spec = get_openapi_spec() |
| 65 | entities = spec["paths"]["/knowledge-graph/{id}/entities"] |
| 66 | assert "get" in entities |
| 67 | |
| 68 | |
| 69 | def test_relationships_endpoint(): |
| 70 | spec = get_openapi_spec() |
| 71 | rels = spec["paths"]["/knowledge-graph/{id}/relationships"] |
| 72 | assert "get" in rels |
| 73 | |
| 74 | |
| 75 | def test_query_endpoint(): |
| 76 | spec = get_openapi_spec() |
| 77 | query = spec["paths"]["/knowledge-graph/{id}/query"] |
| 78 | assert "get" in query |
| 79 | params = query["get"]["parameters"] |
| 80 | param_names = [p["name"] for p in params] |
| 81 | assert "q" in param_names |
| 82 | |
| 83 | |
| 84 | def test_component_schemas(): |
| 85 | spec = get_openapi_spec() |
| 86 | schemas = spec["components"]["schemas"] |
| 87 | for schema_name in ("Job", "Entity", "Relationship"): |
| 88 | assert schema_name in schemas, f"Missing schema: {schema_name}" |
| 89 | |
| 90 | |
| 91 | def test_job_schema_properties(): |
| 92 | spec = get_openapi_spec() |
| 93 | job = spec["components"]["schemas"]["Job"] |
| 94 | props = job["properties"] |
| 95 | assert "id" in props |
| 96 | assert "status" in props |
| 97 | assert "progress" in props |
| 98 | |
| 99 | |
| 100 | def test_job_status_enum(): |
| 101 | spec = get_openapi_spec() |
| 102 | status = spec["components"]["schemas"]["Job"]["properties"]["status"] |
| 103 | assert "enum" in status |
| 104 | assert "pending" in status["enum"] |
| 105 | assert "completed" in status["enum"] |
| 106 | |
| 107 | |
| 108 | def test_analyze_request_body_schema(): |
| 109 | spec = get_openapi_spec() |
| 110 | schema = spec["paths"]["/analyze"]["post"]["requestBody"]["content"]["application/json"][ |
| 111 | "schema" |
| 112 | ] |
| 113 | assert "video_url" in schema["properties"] |
| 114 | assert "video_url" in schema["required"] |
+309
| --- a/tests/test_auth.py | ||
| +++ b/tests/test_auth.py | ||
| @@ -0,0 +1,309 @@ | ||
| 1 | +"""Tests for the unified auth module.""" | |
| 2 | + | |
| 3 | +import json | |
| 4 | +import os | |
| 5 | +import time | |
| 6 | +from pathlib import Path | |
| 7 | +from unittest.mock import MagicMock, patch | |
| 8 | + | |
| 9 | +from video_processor.auth import ( | |
| 10 | + KNOWN_CONFIGS, | |
| 11 | + AuthConfig, | |
| 12 | + AuthResult, | |
| 13 | + OAuthManager, | |
| 14 | + get_auth_config, | |
| 15 | + get_auth_manager, | |
| 16 | +) | |
| 17 | + | |
| 18 | +# ----------------------------------------------------------------------- | |
| 19 | +# AuthConfig | |
| 20 | +# ----------------------------------------------------------------------- | |
| 21 | + | |
| 22 | + | |
| 23 | +class TestAuthConfig: | |
| 24 | + def test_basic_creation(self): | |
| 25 | + config = AuthConfig(service="test") | |
| 26 | + assert config.service == "test" | |
| 27 | + assert config.supports_oauth is False | |
| 28 | + | |
| 29 | + def test_with_oauth_endpoints(self): | |
| 30 | + config = AuthConfig( | |
| 31 | + service="test", | |
| 32 | + oauth_authorize_url="https://example.com/auth", | |
| 33 | + oauth_token_url="https://example.com/token", | |
| 34 | + ) | |
| 35 | + assert config.supports_oauth is True | |
| 36 | + | |
| 37 | + def test_resolved_client_id_from_env(self): | |
| 38 | + config = AuthConfig( | |
| 39 | + service="test", | |
| 40 | + client_id_env="TEST_CLIENT_ID", | |
| 41 | + ) | |
| 42 | + with patch.dict(os.environ, {"TEST_CLIENT_ID": "my-id"}): | |
| 43 | + assert config.resolved_client_id == "my-id" | |
| 44 | + | |
| 45 | + def test_resolved_client_id_explicit(self): | |
| 46 | + config = AuthConfig( | |
| 47 | + service="test", | |
| 48 | + client_id="explicit-id", | |
| 49 | + client_id_env="TEST_CLIENT_ID", | |
| 50 | + ) | |
| 51 | + assert config.resolved_client_id == "explicit-id" | |
| 52 | + | |
| 53 | + def test_resolved_api_key(self): | |
| 54 | + config = AuthConfig(service="test", api_key_env="TEST_API_KEY") | |
| 55 | + with patch.dict(os.environ, {"TEST_API_KEY": "sk-123"}): | |
| 56 | + assert config.resolved_api_key == "sk-123" | |
| 57 | + | |
| 58 | + def test_resolved_api_key_empty(self): | |
| 59 | + config = AuthConfig(service="test", api_key_env="TEST_API_KEY") | |
| 60 | + with patch.dict(os.environ, {}, clear=True): | |
| 61 | + assert config.resolved_api_key is None | |
| 62 | + | |
| 63 | + def test_resolved_token_path_default(self): | |
| 64 | + config = AuthConfig(service="zoom") | |
| 65 | + assert config.resolved_token_path.name == "zoom_token.json" | |
| 66 | + | |
| 67 | + def test_resolved_token_path_custom(self): | |
| 68 | + config = AuthConfig( | |
| 69 | + service="zoom", | |
| 70 | + token_path=Path("/tmp/custom.json"), | |
| 71 | + ) | |
| 72 | + assert config.resolved_token_path == Path("/tmp/custom.json") | |
| 73 | + | |
| 74 | + def test_resolved_account_id(self): | |
| 75 | + config = AuthConfig( | |
| 76 | + service="test", | |
| 77 | + account_id_env="TEST_ACCOUNT_ID", | |
| 78 | + ) | |
| 79 | + with patch.dict(os.environ, {"TEST_ACCOUNT_ID": "acc-123"}): | |
| 80 | + assert config.resolved_account_id == "acc-123" | |
| 81 | + | |
| 82 | + | |
| 83 | +# ----------------------------------------------------------------------- | |
| 84 | +# AuthResult | |
| 85 | +# ----------------------------------------------------------------------- | |
| 86 | + | |
| 87 | + | |
| 88 | +class TestAuthResult: | |
| 89 | + def test_success(self): | |
| 90 | + result = AuthResult( | |
| 91 | + success=True, | |
| 92 | + access_token="tok", | |
| 93 | + method="api_key", | |
| 94 | + ) | |
| 95 | + assert result.success | |
| 96 | + assert result.access_token == "tok" | |
| 97 | + | |
| 98 | + def test_failure(self): | |
| 99 | + result = AuthResult(success=False, error="no key") | |
| 100 | + assert not result.success | |
| 101 | + assert result.error == "no key" | |
| 102 | + | |
| 103 | + | |
| 104 | +# ----------------------------------------------------------------------- | |
| 105 | +# OAuthManager | |
| 106 | +# ----------------------------------------------------------------------- | |
| 107 | + | |
| 108 | + | |
| 109 | +class TestOAuthManager: | |
| 110 | + def test_api_key_fallback(self): | |
| 111 | + config = AuthConfig( | |
| 112 | + service="test", | |
| 113 | + api_key_env="TEST_KEY", | |
| 114 | + ) | |
| 115 | + manager = OAuthManager(config) | |
| 116 | + with patch.dict(os.environ, {"TEST_KEY": "sk-abc"}): | |
| 117 | + result = manager.authenticate() | |
| 118 | + assert result.success | |
| 119 | + assert result.access_token == "sk-abc" | |
| 120 | + assert result.method == "api_key" | |
| 121 | + | |
| 122 | + def test_no_auth_available(self): | |
| 123 | + config = AuthConfig(service="test") | |
| 124 | + manager = OAuthManager(config) | |
| 125 | + with patch.dict(os.environ, {}, clear=True): | |
| 126 | + result = manager.authenticate() | |
| 127 | + assert not result.success | |
| 128 | + assert "No auth method" in result.error | |
| 129 | + | |
| 130 | + def test_saved_token_valid(self, tmp_path): | |
| 131 | + token_file = tmp_path / "token.json" | |
| 132 | + token_data = { | |
| 133 | + "access_token": "saved-tok", | |
| 134 | + "expires_at": time.time() + 3600, | |
| 135 | + } | |
| 136 | + token_file.write_text(json.dumps(token_data)) | |
| 137 | + | |
| 138 | + config = AuthConfig( | |
| 139 | + service="test", | |
| 140 | + token_path=token_file, | |
| 141 | + ) | |
| 142 | + manager = OAuthManager(config) | |
| 143 | + result = manager.authenticate() | |
| 144 | + assert result.success | |
| 145 | + assert result.access_token == "saved-tok" | |
| 146 | + assert result.method == "saved_token" | |
| 147 | + | |
| 148 | + def test_saved_token_expired_no_refresh(self, tmp_path): | |
| 149 | + token_file = tmp_path / "token.json" | |
| 150 | + token_data = { | |
| 151 | + "access_token": "old-tok", | |
| 152 | + "expires_at": time.time() - 100, | |
| 153 | + } | |
| 154 | + token_file.write_text(json.dumps(token_data)) | |
| 155 | + | |
| 156 | + config = AuthConfig( | |
| 157 | + service="test", | |
| 158 | + token_path=token_file, | |
| 159 | + ) | |
| 160 | + manager = OAuthManager(config) | |
| 161 | + result = manager.authenticate() | |
| 162 | + assert not result.success | |
| 163 | + | |
| 164 | + def test_get_token_convenience(self): | |
| 165 | + config = AuthConfig( | |
| 166 | + service="test", | |
| 167 | + api_key_env="TEST_KEY", | |
| 168 | + ) | |
| 169 | + manager = OAuthManager(config) | |
| 170 | + with patch.dict(os.environ, {"TEST_KEY": "sk-xyz"}): | |
| 171 | + token = manager.get_token() | |
| 172 | + assert token == "sk-xyz" | |
| 173 | + | |
| 174 | + def test_get_token_none_on_failure(self): | |
| 175 | + config = AuthConfig(service="test") | |
| 176 | + manager = OAuthManager(config) | |
| 177 | + with patch.dict(os.environ, {}, clear=True): | |
| 178 | + token = manager.get_token() | |
| 179 | + assert token is None | |
| 180 | + | |
| 181 | + def test_clear_token(self, tmp_path): | |
| 182 | + token_file = tmp_path / "token.json" | |
| 183 | + token_file.write_text("{}") | |
| 184 | + config = AuthConfig(service="test", token_path=token_file) | |
| 185 | + manager = OAuthManager(config) | |
| 186 | + manager.clear_token() | |
| 187 | + assert not token_file.exists() | |
| 188 | + | |
| 189 | + def test_clear_token_no_file(self, tmp_path): | |
| 190 | + config = AuthConfig( | |
| 191 | + service="test", | |
| 192 | + token_path=tmp_path / "nonexistent.json", | |
| 193 | + ) | |
| 194 | + manager = OAuthManager(config) | |
| 195 | + manager.clear_token() # should not raise | |
| 196 | + | |
| 197 | + def test_save_token_creates_dir(self, tmp_path): | |
| 198 | + nested = tmp_path / "deep" / "dir" / "token.json" | |
| 199 | + config = AuthConfig(service="test", token_path=nested) | |
| 200 | + manager = OAuthManager(config) | |
| 201 | + manager._save_token({"access_token": "tok"}) | |
| 202 | + assert nested.exists() | |
| 203 | + data = json.loads(nested.read_text()) | |
| 204 | + assert data["access_token"] == "tok" | |
| 205 | + | |
| 206 | + def test_saved_token_expired_with_refresh(self, tmp_path): | |
| 207 | + token_file = tmp_path / "token.json" | |
| 208 | + token_data = { | |
| 209 | + "access_token": "old-tok", | |
| 210 | + "refresh_token": "ref-tok", | |
| 211 | + "expires_at": time.time() - 100, | |
| 212 | + "client_id": "cid", | |
| 213 | + "client_secret": "csec", | |
| 214 | + } | |
| 215 | + token_file.write_text(json.dumps(token_data)) | |
| 216 | + | |
| 217 | + config = AuthConfig( | |
| 218 | + service="test", | |
| 219 | + oauth_token_url="https://example.com/token", | |
| 220 | + token_path=token_file, | |
| 221 | + ) | |
| 222 | + manager = OAuthManager(config) | |
| 223 | + | |
| 224 | + mock_resp = MagicMock() | |
| 225 | + mock_resp.json.return_value = { | |
| 226 | + "access_token": "new-tok", | |
| 227 | + "refresh_token": "new-ref", | |
| 228 | + "expires_in": 7200, | |
| 229 | + } | |
| 230 | + mock_resp.raise_for_status = MagicMock() | |
| 231 | + | |
| 232 | + with patch("requests.post", return_value=mock_resp): | |
| 233 | + result = manager.authenticate() | |
| 234 | + | |
| 235 | + assert result.success | |
| 236 | + assert result.access_token == "new-tok" | |
| 237 | + assert result.method == "saved_token" | |
| 238 | + | |
| 239 | + def test_oauth_prefers_saved_over_api_key(self, tmp_path): | |
| 240 | + """Saved token should be tried before API key fallback.""" | |
| 241 | + token_file = tmp_path / "token.json" | |
| 242 | + token_data = { | |
| 243 | + "access_token": "saved-tok", | |
| 244 | + "expires_at": time.time() + 3600, | |
| 245 | + } | |
| 246 | + token_file.write_text(json.dumps(token_data)) | |
| 247 | + | |
| 248 | + config = AuthConfig( | |
| 249 | + service="test", | |
| 250 | + api_key_env="TEST_KEY", | |
| 251 | + token_path=token_file, | |
| 252 | + ) | |
| 253 | + manager = OAuthManager(config) | |
| 254 | + with patch.dict(os.environ, {"TEST_KEY": "api-key"}): | |
| 255 | + result = manager.authenticate() | |
| 256 | + | |
| 257 | + assert result.access_token == "saved-tok" | |
| 258 | + assert result.method == "saved_token" | |
| 259 | + | |
| 260 | + | |
| 261 | +# ----------------------------------------------------------------------- | |
| 262 | +# Known configs and helpers | |
| 263 | +# ----------------------------------------------------------------------- | |
| 264 | + | |
| 265 | + | |
| 266 | +class TestKnownConfigs: | |
| 267 | + def test_zoom_config(self): | |
| 268 | + config = KNOWN_CONFIGS["zoom"] | |
| 269 | + assert config.service == "zoom" | |
| 270 | + assert config.supports_oauth | |
| 271 | + assert config.client_id_env == "ZOOM_CLIENT_ID" | |
| 272 | + | |
| 273 | + def test_notion_config(self): | |
| 274 | + config = KNOWN_CONFIGS["notion"] | |
| 275 | + assert config.api_key_env == "NOTION_API_KEY" | |
| 276 | + assert config.supports_oauth | |
| 277 | + | |
| 278 | + def test_github_config(self): | |
| 279 | + config = KNOWN_CONFIGS["github"] | |
| 280 | + assert config.api_key_env == "GITHUB_TOKEN" | |
| 281 | + assert "repo" in config.scopes | |
| 282 | + | |
| 283 | + def test_dropbox_config(self): | |
| 284 | + config = KNOWN_CONFIGS["dropbox"] | |
| 285 | + assert config.api_key_env == "DROPBOX_ACCESS_TOKEN" | |
| 286 | + | |
| 287 | + def test_google_config(self): | |
| 288 | + config = KNOWN_CONFIGS["google"] | |
| 289 | + assert config.supports_oauth | |
| 290 | + | |
| 291 | + def test_microsoft_config(self): | |
| 292 | + config = KNOWN_CONFIGS["microsoft"] | |
| 293 | + assert config.supports_oauth | |
| 294 | + | |
| 295 | + def test_all_configs_have_service(self): | |
| 296 | + for name, config in KNOWN_CONFIGS.items(): | |
| 297 | + assert config.service == name | |
| 298 | + | |
| 299 | + def test_get_auth_config(self): | |
| 300 | + assert get_auth_config("zoom") is not None | |
| 301 | + assert get_auth_config("nonexistent") is None | |
| 302 | + | |
| 303 | + def test_get_auth_manager(self): | |
| 304 | + mgr = get_auth_manager("zoom") | |
| 305 | + assert mgr is not None | |
| 306 | + assert isinstance(mgr, OAuthManager) | |
| 307 | + | |
| 308 | + def test_get_auth_manager_unknown(self): | |
| 309 | + assert get_auth_manager("nonexistent") is None |
| --- a/tests/test_auth.py | |
| +++ b/tests/test_auth.py | |
| @@ -0,0 +1,309 @@ | |
| --- a/tests/test_auth.py | |
| +++ b/tests/test_auth.py | |
| @@ -0,0 +1,309 @@ | |
| 1 | """Tests for the unified auth module.""" |
| 2 | |
| 3 | import json |
| 4 | import os |
| 5 | import time |
| 6 | from pathlib import Path |
| 7 | from unittest.mock import MagicMock, patch |
| 8 | |
| 9 | from video_processor.auth import ( |
| 10 | KNOWN_CONFIGS, |
| 11 | AuthConfig, |
| 12 | AuthResult, |
| 13 | OAuthManager, |
| 14 | get_auth_config, |
| 15 | get_auth_manager, |
| 16 | ) |
| 17 | |
| 18 | # ----------------------------------------------------------------------- |
| 19 | # AuthConfig |
| 20 | # ----------------------------------------------------------------------- |
| 21 | |
| 22 | |
| 23 | class TestAuthConfig: |
| 24 | def test_basic_creation(self): |
| 25 | config = AuthConfig(service="test") |
| 26 | assert config.service == "test" |
| 27 | assert config.supports_oauth is False |
| 28 | |
| 29 | def test_with_oauth_endpoints(self): |
| 30 | config = AuthConfig( |
| 31 | service="test", |
| 32 | oauth_authorize_url="https://example.com/auth", |
| 33 | oauth_token_url="https://example.com/token", |
| 34 | ) |
| 35 | assert config.supports_oauth is True |
| 36 | |
| 37 | def test_resolved_client_id_from_env(self): |
| 38 | config = AuthConfig( |
| 39 | service="test", |
| 40 | client_id_env="TEST_CLIENT_ID", |
| 41 | ) |
| 42 | with patch.dict(os.environ, {"TEST_CLIENT_ID": "my-id"}): |
| 43 | assert config.resolved_client_id == "my-id" |
| 44 | |
| 45 | def test_resolved_client_id_explicit(self): |
| 46 | config = AuthConfig( |
| 47 | service="test", |
| 48 | client_id="explicit-id", |
| 49 | client_id_env="TEST_CLIENT_ID", |
| 50 | ) |
| 51 | assert config.resolved_client_id == "explicit-id" |
| 52 | |
| 53 | def test_resolved_api_key(self): |
| 54 | config = AuthConfig(service="test", api_key_env="TEST_API_KEY") |
| 55 | with patch.dict(os.environ, {"TEST_API_KEY": "sk-123"}): |
| 56 | assert config.resolved_api_key == "sk-123" |
| 57 | |
| 58 | def test_resolved_api_key_empty(self): |
| 59 | config = AuthConfig(service="test", api_key_env="TEST_API_KEY") |
| 60 | with patch.dict(os.environ, {}, clear=True): |
| 61 | assert config.resolved_api_key is None |
| 62 | |
| 63 | def test_resolved_token_path_default(self): |
| 64 | config = AuthConfig(service="zoom") |
| 65 | assert config.resolved_token_path.name == "zoom_token.json" |
| 66 | |
| 67 | def test_resolved_token_path_custom(self): |
| 68 | config = AuthConfig( |
| 69 | service="zoom", |
| 70 | token_path=Path("/tmp/custom.json"), |
| 71 | ) |
| 72 | assert config.resolved_token_path == Path("/tmp/custom.json") |
| 73 | |
| 74 | def test_resolved_account_id(self): |
| 75 | config = AuthConfig( |
| 76 | service="test", |
| 77 | account_id_env="TEST_ACCOUNT_ID", |
| 78 | ) |
| 79 | with patch.dict(os.environ, {"TEST_ACCOUNT_ID": "acc-123"}): |
| 80 | assert config.resolved_account_id == "acc-123" |
| 81 | |
| 82 | |
| 83 | # ----------------------------------------------------------------------- |
| 84 | # AuthResult |
| 85 | # ----------------------------------------------------------------------- |
| 86 | |
| 87 | |
| 88 | class TestAuthResult: |
| 89 | def test_success(self): |
| 90 | result = AuthResult( |
| 91 | success=True, |
| 92 | access_token="tok", |
| 93 | method="api_key", |
| 94 | ) |
| 95 | assert result.success |
| 96 | assert result.access_token == "tok" |
| 97 | |
| 98 | def test_failure(self): |
| 99 | result = AuthResult(success=False, error="no key") |
| 100 | assert not result.success |
| 101 | assert result.error == "no key" |
| 102 | |
| 103 | |
| 104 | # ----------------------------------------------------------------------- |
| 105 | # OAuthManager |
| 106 | # ----------------------------------------------------------------------- |
| 107 | |
| 108 | |
| 109 | class TestOAuthManager: |
| 110 | def test_api_key_fallback(self): |
| 111 | config = AuthConfig( |
| 112 | service="test", |
| 113 | api_key_env="TEST_KEY", |
| 114 | ) |
| 115 | manager = OAuthManager(config) |
| 116 | with patch.dict(os.environ, {"TEST_KEY": "sk-abc"}): |
| 117 | result = manager.authenticate() |
| 118 | assert result.success |
| 119 | assert result.access_token == "sk-abc" |
| 120 | assert result.method == "api_key" |
| 121 | |
| 122 | def test_no_auth_available(self): |
| 123 | config = AuthConfig(service="test") |
| 124 | manager = OAuthManager(config) |
| 125 | with patch.dict(os.environ, {}, clear=True): |
| 126 | result = manager.authenticate() |
| 127 | assert not result.success |
| 128 | assert "No auth method" in result.error |
| 129 | |
| 130 | def test_saved_token_valid(self, tmp_path): |
| 131 | token_file = tmp_path / "token.json" |
| 132 | token_data = { |
| 133 | "access_token": "saved-tok", |
| 134 | "expires_at": time.time() + 3600, |
| 135 | } |
| 136 | token_file.write_text(json.dumps(token_data)) |
| 137 | |
| 138 | config = AuthConfig( |
| 139 | service="test", |
| 140 | token_path=token_file, |
| 141 | ) |
| 142 | manager = OAuthManager(config) |
| 143 | result = manager.authenticate() |
| 144 | assert result.success |
| 145 | assert result.access_token == "saved-tok" |
| 146 | assert result.method == "saved_token" |
| 147 | |
| 148 | def test_saved_token_expired_no_refresh(self, tmp_path): |
| 149 | token_file = tmp_path / "token.json" |
| 150 | token_data = { |
| 151 | "access_token": "old-tok", |
| 152 | "expires_at": time.time() - 100, |
| 153 | } |
| 154 | token_file.write_text(json.dumps(token_data)) |
| 155 | |
| 156 | config = AuthConfig( |
| 157 | service="test", |
| 158 | token_path=token_file, |
| 159 | ) |
| 160 | manager = OAuthManager(config) |
| 161 | result = manager.authenticate() |
| 162 | assert not result.success |
| 163 | |
| 164 | def test_get_token_convenience(self): |
| 165 | config = AuthConfig( |
| 166 | service="test", |
| 167 | api_key_env="TEST_KEY", |
| 168 | ) |
| 169 | manager = OAuthManager(config) |
| 170 | with patch.dict(os.environ, {"TEST_KEY": "sk-xyz"}): |
| 171 | token = manager.get_token() |
| 172 | assert token == "sk-xyz" |
| 173 | |
| 174 | def test_get_token_none_on_failure(self): |
| 175 | config = AuthConfig(service="test") |
| 176 | manager = OAuthManager(config) |
| 177 | with patch.dict(os.environ, {}, clear=True): |
| 178 | token = manager.get_token() |
| 179 | assert token is None |
| 180 | |
| 181 | def test_clear_token(self, tmp_path): |
| 182 | token_file = tmp_path / "token.json" |
| 183 | token_file.write_text("{}") |
| 184 | config = AuthConfig(service="test", token_path=token_file) |
| 185 | manager = OAuthManager(config) |
| 186 | manager.clear_token() |
| 187 | assert not token_file.exists() |
| 188 | |
| 189 | def test_clear_token_no_file(self, tmp_path): |
| 190 | config = AuthConfig( |
| 191 | service="test", |
| 192 | token_path=tmp_path / "nonexistent.json", |
| 193 | ) |
| 194 | manager = OAuthManager(config) |
| 195 | manager.clear_token() # should not raise |
| 196 | |
| 197 | def test_save_token_creates_dir(self, tmp_path): |
| 198 | nested = tmp_path / "deep" / "dir" / "token.json" |
| 199 | config = AuthConfig(service="test", token_path=nested) |
| 200 | manager = OAuthManager(config) |
| 201 | manager._save_token({"access_token": "tok"}) |
| 202 | assert nested.exists() |
| 203 | data = json.loads(nested.read_text()) |
| 204 | assert data["access_token"] == "tok" |
| 205 | |
| 206 | def test_saved_token_expired_with_refresh(self, tmp_path): |
| 207 | token_file = tmp_path / "token.json" |
| 208 | token_data = { |
| 209 | "access_token": "old-tok", |
| 210 | "refresh_token": "ref-tok", |
| 211 | "expires_at": time.time() - 100, |
| 212 | "client_id": "cid", |
| 213 | "client_secret": "csec", |
| 214 | } |
| 215 | token_file.write_text(json.dumps(token_data)) |
| 216 | |
| 217 | config = AuthConfig( |
| 218 | service="test", |
| 219 | oauth_token_url="https://example.com/token", |
| 220 | token_path=token_file, |
| 221 | ) |
| 222 | manager = OAuthManager(config) |
| 223 | |
| 224 | mock_resp = MagicMock() |
| 225 | mock_resp.json.return_value = { |
| 226 | "access_token": "new-tok", |
| 227 | "refresh_token": "new-ref", |
| 228 | "expires_in": 7200, |
| 229 | } |
| 230 | mock_resp.raise_for_status = MagicMock() |
| 231 | |
| 232 | with patch("requests.post", return_value=mock_resp): |
| 233 | result = manager.authenticate() |
| 234 | |
| 235 | assert result.success |
| 236 | assert result.access_token == "new-tok" |
| 237 | assert result.method == "saved_token" |
| 238 | |
| 239 | def test_oauth_prefers_saved_over_api_key(self, tmp_path): |
| 240 | """Saved token should be tried before API key fallback.""" |
| 241 | token_file = tmp_path / "token.json" |
| 242 | token_data = { |
| 243 | "access_token": "saved-tok", |
| 244 | "expires_at": time.time() + 3600, |
| 245 | } |
| 246 | token_file.write_text(json.dumps(token_data)) |
| 247 | |
| 248 | config = AuthConfig( |
| 249 | service="test", |
| 250 | api_key_env="TEST_KEY", |
| 251 | token_path=token_file, |
| 252 | ) |
| 253 | manager = OAuthManager(config) |
| 254 | with patch.dict(os.environ, {"TEST_KEY": "api-key"}): |
| 255 | result = manager.authenticate() |
| 256 | |
| 257 | assert result.access_token == "saved-tok" |
| 258 | assert result.method == "saved_token" |
| 259 | |
| 260 | |
| 261 | # ----------------------------------------------------------------------- |
| 262 | # Known configs and helpers |
| 263 | # ----------------------------------------------------------------------- |
| 264 | |
| 265 | |
| 266 | class TestKnownConfigs: |
| 267 | def test_zoom_config(self): |
| 268 | config = KNOWN_CONFIGS["zoom"] |
| 269 | assert config.service == "zoom" |
| 270 | assert config.supports_oauth |
| 271 | assert config.client_id_env == "ZOOM_CLIENT_ID" |
| 272 | |
| 273 | def test_notion_config(self): |
| 274 | config = KNOWN_CONFIGS["notion"] |
| 275 | assert config.api_key_env == "NOTION_API_KEY" |
| 276 | assert config.supports_oauth |
| 277 | |
| 278 | def test_github_config(self): |
| 279 | config = KNOWN_CONFIGS["github"] |
| 280 | assert config.api_key_env == "GITHUB_TOKEN" |
| 281 | assert "repo" in config.scopes |
| 282 | |
| 283 | def test_dropbox_config(self): |
| 284 | config = KNOWN_CONFIGS["dropbox"] |
| 285 | assert config.api_key_env == "DROPBOX_ACCESS_TOKEN" |
| 286 | |
| 287 | def test_google_config(self): |
| 288 | config = KNOWN_CONFIGS["google"] |
| 289 | assert config.supports_oauth |
| 290 | |
| 291 | def test_microsoft_config(self): |
| 292 | config = KNOWN_CONFIGS["microsoft"] |
| 293 | assert config.supports_oauth |
| 294 | |
| 295 | def test_all_configs_have_service(self): |
| 296 | for name, config in KNOWN_CONFIGS.items(): |
| 297 | assert config.service == name |
| 298 | |
| 299 | def test_get_auth_config(self): |
| 300 | assert get_auth_config("zoom") is not None |
| 301 | assert get_auth_config("nonexistent") is None |
| 302 | |
| 303 | def test_get_auth_manager(self): |
| 304 | mgr = get_auth_manager("zoom") |
| 305 | assert mgr is not None |
| 306 | assert isinstance(mgr, OAuthManager) |
| 307 | |
| 308 | def test_get_auth_manager_unknown(self): |
| 309 | assert get_auth_manager("nonexistent") is None |
+114
| --- a/tests/test_callbacks.py | ||
| +++ b/tests/test_callbacks.py | ||
| @@ -0,0 +1,114 @@ | ||
| 1 | +"""Tests for video_processor.utils.callbacks.WebhookCallback.""" | |
| 2 | + | |
| 3 | +import json | |
| 4 | +from unittest.mock import patch | |
| 5 | + | |
| 6 | +import pytest | |
| 7 | + | |
| 8 | +from video_processor.utils.callbacks import WebhookCallback | |
| 9 | + | |
| 10 | + | |
| 11 | +@pytest.fixture() | |
| 12 | +def callback(): | |
| 13 | + return WebhookCallback(url="https://example.com/webhook") | |
| 14 | + | |
| 15 | + | |
| 16 | +# --- Constructor --- | |
| 17 | + | |
| 18 | + | |
| 19 | +def test_default_headers(): | |
| 20 | + cb = WebhookCallback(url="https://example.com/hook") | |
| 21 | + assert cb.headers == {"Content-Type": "application/json"} | |
| 22 | + | |
| 23 | + | |
| 24 | +def test_custom_headers(): | |
| 25 | + headers = {"Authorization": "Bearer tok", "Content-Type": "application/json"} | |
| 26 | + cb = WebhookCallback(url="https://example.com/hook", headers=headers) | |
| 27 | + assert cb.headers["Authorization"] == "Bearer tok" | |
| 28 | + | |
| 29 | + | |
| 30 | +def test_custom_timeout(): | |
| 31 | + cb = WebhookCallback(url="https://example.com/hook", timeout=5.0) | |
| 32 | + assert cb.timeout == 5.0 | |
| 33 | + | |
| 34 | + | |
| 35 | +# --- _post --- | |
| 36 | + | |
| 37 | + | |
| 38 | +@patch("urllib.request.urlopen") | |
| 39 | +@patch("urllib.request.Request") | |
| 40 | +def test_post_sends_json_payload(mock_request_cls, mock_urlopen, callback): | |
| 41 | + callback._post({"event": "test"}) | |
| 42 | + | |
| 43 | + mock_request_cls.assert_called_once() | |
| 44 | + call_args = mock_request_cls.call_args | |
| 45 | + data = json.loads(call_args[1]["data"] if "data" in call_args[1] else call_args[0][1]) | |
| 46 | + assert data["event"] == "test" | |
| 47 | + mock_urlopen.assert_called_once() | |
| 48 | + | |
| 49 | + | |
| 50 | +@patch("urllib.request.urlopen", side_effect=Exception("Connection refused")) | |
| 51 | +@patch("urllib.request.Request") | |
| 52 | +def test_post_logs_failure_does_not_raise(mock_request_cls, mock_urlopen, callback): | |
| 53 | + # Should not raise | |
| 54 | + callback._post({"event": "fail_test"}) | |
| 55 | + | |
| 56 | + | |
| 57 | +# --- on_step_start --- | |
| 58 | + | |
| 59 | + | |
| 60 | +@patch.object(WebhookCallback, "_post") | |
| 61 | +def test_on_step_start_payload(mock_post, callback): | |
| 62 | + callback.on_step_start("transcription", 1, 5) | |
| 63 | + | |
| 64 | + mock_post.assert_called_once_with( | |
| 65 | + { | |
| 66 | + "event": "step_start", | |
| 67 | + "step": "transcription", | |
| 68 | + "index": 1, | |
| 69 | + "total": 5, | |
| 70 | + } | |
| 71 | + ) | |
| 72 | + | |
| 73 | + | |
| 74 | +# --- on_step_complete --- | |
| 75 | + | |
| 76 | + | |
| 77 | +@patch.object(WebhookCallback, "_post") | |
| 78 | +def test_on_step_complete_payload(mock_post, callback): | |
| 79 | + callback.on_step_complete("analysis", 3, 5) | |
| 80 | + | |
| 81 | + mock_post.assert_called_once_with( | |
| 82 | + { | |
| 83 | + "event": "step_complete", | |
| 84 | + "step": "analysis", | |
| 85 | + "index": 3, | |
| 86 | + "total": 5, | |
| 87 | + } | |
| 88 | + ) | |
| 89 | + | |
| 90 | + | |
| 91 | +# --- on_progress --- | |
| 92 | + | |
| 93 | + | |
| 94 | +@patch.object(WebhookCallback, "_post") | |
| 95 | +def test_on_progress_payload(mock_post, callback): | |
| 96 | + callback.on_progress("transcription", 42.5, "Processing chunk 3/7") | |
| 97 | + | |
| 98 | + mock_post.assert_called_once_with( | |
| 99 | + { | |
| 100 | + "event": "progress", | |
| 101 | + "step": "transcription", | |
| 102 | + "percent": 42.5, | |
| 103 | + "message": "Processing chunk 3/7", | |
| 104 | + } | |
| 105 | + ) | |
| 106 | + | |
| 107 | + | |
| 108 | +@patch.object(WebhookCallback, "_post") | |
| 109 | +def test_on_progress_default_message(mock_post, callback): | |
| 110 | + callback.on_progress("extraction", 100.0) | |
| 111 | + | |
| 112 | + payload = mock_post.call_args[0][0] | |
| 113 | + assert payload["message"] == "" | |
| 114 | + assert payload["percent"] == 100.0 |
| --- a/tests/test_callbacks.py | |
| +++ b/tests/test_callbacks.py | |
| @@ -0,0 +1,114 @@ | |
| --- a/tests/test_callbacks.py | |
| +++ b/tests/test_callbacks.py | |
| @@ -0,0 +1,114 @@ | |
| 1 | """Tests for video_processor.utils.callbacks.WebhookCallback.""" |
| 2 | |
| 3 | import json |
| 4 | from unittest.mock import patch |
| 5 | |
| 6 | import pytest |
| 7 | |
| 8 | from video_processor.utils.callbacks import WebhookCallback |
| 9 | |
| 10 | |
| 11 | @pytest.fixture() |
| 12 | def callback(): |
| 13 | return WebhookCallback(url="https://example.com/webhook") |
| 14 | |
| 15 | |
| 16 | # --- Constructor --- |
| 17 | |
| 18 | |
| 19 | def test_default_headers(): |
| 20 | cb = WebhookCallback(url="https://example.com/hook") |
| 21 | assert cb.headers == {"Content-Type": "application/json"} |
| 22 | |
| 23 | |
| 24 | def test_custom_headers(): |
| 25 | headers = {"Authorization": "Bearer tok", "Content-Type": "application/json"} |
| 26 | cb = WebhookCallback(url="https://example.com/hook", headers=headers) |
| 27 | assert cb.headers["Authorization"] == "Bearer tok" |
| 28 | |
| 29 | |
| 30 | def test_custom_timeout(): |
| 31 | cb = WebhookCallback(url="https://example.com/hook", timeout=5.0) |
| 32 | assert cb.timeout == 5.0 |
| 33 | |
| 34 | |
| 35 | # --- _post --- |
| 36 | |
| 37 | |
| 38 | @patch("urllib.request.urlopen") |
| 39 | @patch("urllib.request.Request") |
| 40 | def test_post_sends_json_payload(mock_request_cls, mock_urlopen, callback): |
| 41 | callback._post({"event": "test"}) |
| 42 | |
| 43 | mock_request_cls.assert_called_once() |
| 44 | call_args = mock_request_cls.call_args |
| 45 | data = json.loads(call_args[1]["data"] if "data" in call_args[1] else call_args[0][1]) |
| 46 | assert data["event"] == "test" |
| 47 | mock_urlopen.assert_called_once() |
| 48 | |
| 49 | |
| 50 | @patch("urllib.request.urlopen", side_effect=Exception("Connection refused")) |
| 51 | @patch("urllib.request.Request") |
| 52 | def test_post_logs_failure_does_not_raise(mock_request_cls, mock_urlopen, callback): |
| 53 | # Should not raise |
| 54 | callback._post({"event": "fail_test"}) |
| 55 | |
| 56 | |
| 57 | # --- on_step_start --- |
| 58 | |
| 59 | |
| 60 | @patch.object(WebhookCallback, "_post") |
| 61 | def test_on_step_start_payload(mock_post, callback): |
| 62 | callback.on_step_start("transcription", 1, 5) |
| 63 | |
| 64 | mock_post.assert_called_once_with( |
| 65 | { |
| 66 | "event": "step_start", |
| 67 | "step": "transcription", |
| 68 | "index": 1, |
| 69 | "total": 5, |
| 70 | } |
| 71 | ) |
| 72 | |
| 73 | |
| 74 | # --- on_step_complete --- |
| 75 | |
| 76 | |
| 77 | @patch.object(WebhookCallback, "_post") |
| 78 | def test_on_step_complete_payload(mock_post, callback): |
| 79 | callback.on_step_complete("analysis", 3, 5) |
| 80 | |
| 81 | mock_post.assert_called_once_with( |
| 82 | { |
| 83 | "event": "step_complete", |
| 84 | "step": "analysis", |
| 85 | "index": 3, |
| 86 | "total": 5, |
| 87 | } |
| 88 | ) |
| 89 | |
| 90 | |
| 91 | # --- on_progress --- |
| 92 | |
| 93 | |
| 94 | @patch.object(WebhookCallback, "_post") |
| 95 | def test_on_progress_payload(mock_post, callback): |
| 96 | callback.on_progress("transcription", 42.5, "Processing chunk 3/7") |
| 97 | |
| 98 | mock_post.assert_called_once_with( |
| 99 | { |
| 100 | "event": "progress", |
| 101 | "step": "transcription", |
| 102 | "percent": 42.5, |
| 103 | "message": "Processing chunk 3/7", |
| 104 | } |
| 105 | ) |
| 106 | |
| 107 | |
| 108 | @patch.object(WebhookCallback, "_post") |
| 109 | def test_on_progress_default_message(mock_post, callback): |
| 110 | callback.on_progress("extraction", 100.0) |
| 111 | |
| 112 | payload = mock_post.call_args[0][0] |
| 113 | assert payload["message"] == "" |
| 114 | assert payload["percent"] == 100.0 |
+252
| --- a/tests/test_cli.py | ||
| +++ b/tests/test_cli.py | ||
| @@ -0,0 +1,252 @@ | ||
| 1 | +"""Tests for the CLI commands (help text, version, option presence).""" | |
| 2 | + | |
| 3 | +from click.testing import CliRunner | |
| 4 | + | |
| 5 | +from video_processor.cli.commands import cli | |
| 6 | + | |
| 7 | + | |
| 8 | +class TestCLIRoot: | |
| 9 | + def test_version(self): | |
| 10 | + runner = CliRunner() | |
| 11 | + result = runner.invoke(cli, ["--version"]) | |
| 12 | + assert result.exit_code == 0 | |
| 13 | + assert "PlanOpticon" in result.output | |
| 14 | + assert "0.4.0" in result.output # matches @click.version_option | |
| 15 | + | |
| 16 | + def test_help(self): | |
| 17 | + runner = CliRunner() | |
| 18 | + result = runner.invoke(cli, ["--help"]) | |
| 19 | + assert result.exit_code == 0 | |
| 20 | + assert "PlanOpticon" in result.output | |
| 21 | + assert "analyze" in result.output | |
| 22 | + assert "query" in result.output | |
| 23 | + assert "agent" in result.output | |
| 24 | + assert "kg" in result.output | |
| 25 | + assert "gws" in result.output | |
| 26 | + assert "m365" in result.output | |
| 27 | + assert "recordings" in result.output | |
| 28 | + assert "ingest" in result.output | |
| 29 | + assert "batch" in result.output | |
| 30 | + assert "--chat" in result.output | |
| 31 | + assert "--interactive" in result.output | |
| 32 | + assert "companion" in result.output | |
| 33 | + | |
| 34 | + | |
| 35 | +class TestAnalyzeHelp: | |
| 36 | + def test_help(self): | |
| 37 | + runner = CliRunner() | |
| 38 | + result = runner.invoke(cli, ["analyze", "--help"]) | |
| 39 | + assert result.exit_code == 0 | |
| 40 | + assert "--input" in result.output or "-i" in result.output | |
| 41 | + assert "--output" in result.output or "-o" in result.output | |
| 42 | + assert "--depth" in result.output | |
| 43 | + assert "--provider" in result.output | |
| 44 | + assert "--output-format" in result.output | |
| 45 | + assert "--templates-dir" in result.output | |
| 46 | + assert "--speakers" in result.output | |
| 47 | + assert "--vision-model" in result.output | |
| 48 | + assert "--chat-model" in result.output | |
| 49 | + assert "--sampling-rate" in result.output | |
| 50 | + assert "--change-threshold" in result.output | |
| 51 | + assert "--periodic-capture" in result.output | |
| 52 | + | |
| 53 | + | |
| 54 | +class TestQueryHelp: | |
| 55 | + def test_help(self): | |
| 56 | + runner = CliRunner() | |
| 57 | + result = runner.invoke(cli, ["query", "--help"]) | |
| 58 | + assert result.exit_code == 0 | |
| 59 | + assert "--db-path" in result.output | |
| 60 | + assert "--mode" in result.output | |
| 61 | + assert "--format" in result.output | |
| 62 | + assert "--interactive" in result.output or "-I" in result.output | |
| 63 | + assert "--provider" in result.output | |
| 64 | + assert "--chat-model" in result.output | |
| 65 | + assert "QUESTION" in result.output | |
| 66 | + | |
| 67 | + | |
| 68 | +class TestAgentHelp: | |
| 69 | + def test_help(self): | |
| 70 | + runner = CliRunner() | |
| 71 | + result = runner.invoke(cli, ["agent", "--help"]) | |
| 72 | + assert result.exit_code == 0 | |
| 73 | + assert "--kb" in result.output | |
| 74 | + assert "--interactive" in result.output or "-I" in result.output | |
| 75 | + assert "--export" in result.output | |
| 76 | + assert "--provider" in result.output | |
| 77 | + assert "--chat-model" in result.output | |
| 78 | + assert "REQUEST" in result.output | |
| 79 | + | |
| 80 | + | |
| 81 | +class TestKGHelp: | |
| 82 | + def test_help(self): | |
| 83 | + runner = CliRunner() | |
| 84 | + result = runner.invoke(cli, ["kg", "--help"]) | |
| 85 | + assert result.exit_code == 0 | |
| 86 | + assert "convert" in result.output | |
| 87 | + assert "Knowledge graph" in result.output | |
| 88 | + | |
| 89 | + def test_convert_help(self): | |
| 90 | + runner = CliRunner() | |
| 91 | + result = runner.invoke(cli, ["kg", "convert", "--help"]) | |
| 92 | + assert result.exit_code == 0 | |
| 93 | + assert "SOURCE_PATH" in result.output | |
| 94 | + assert "DEST_PATH" in result.output | |
| 95 | + | |
| 96 | + | |
| 97 | +class TestIngestHelp: | |
| 98 | + def test_help(self): | |
| 99 | + runner = CliRunner() | |
| 100 | + result = runner.invoke(cli, ["ingest", "--help"]) | |
| 101 | + assert result.exit_code == 0 | |
| 102 | + assert "INPUT_PATH" in result.output | |
| 103 | + assert "--output" in result.output or "-o" in result.output | |
| 104 | + assert "--db-path" in result.output | |
| 105 | + assert "--recursive" in result.output | |
| 106 | + assert "--provider" in result.output | |
| 107 | + assert "--chat-model" in result.output | |
| 108 | + | |
| 109 | + | |
| 110 | +class TestBatchHelp: | |
| 111 | + def test_help(self): | |
| 112 | + runner = CliRunner() | |
| 113 | + result = runner.invoke(cli, ["batch", "--help"]) | |
| 114 | + assert result.exit_code == 0 | |
| 115 | + assert "--input-dir" in result.output or "-i" in result.output | |
| 116 | + assert "--output" in result.output or "-o" in result.output | |
| 117 | + assert "--depth" in result.output | |
| 118 | + assert "--pattern" in result.output | |
| 119 | + assert "--source" in result.output | |
| 120 | + assert "--folder-id" in result.output | |
| 121 | + | |
| 122 | + | |
| 123 | +class TestAgentAnalyzeHelp: | |
| 124 | + def test_help(self): | |
| 125 | + runner = CliRunner() | |
| 126 | + result = runner.invoke(cli, ["agent-analyze", "--help"]) | |
| 127 | + assert result.exit_code == 0 | |
| 128 | + assert "--input" in result.output or "-i" in result.output | |
| 129 | + assert "--output" in result.output or "-o" in result.output | |
| 130 | + assert "--depth" in result.output | |
| 131 | + assert "--provider" in result.output | |
| 132 | + | |
| 133 | + | |
| 134 | +class TestListModelsHelp: | |
| 135 | + def test_help(self): | |
| 136 | + runner = CliRunner() | |
| 137 | + result = runner.invoke(cli, ["list-models", "--help"]) | |
| 138 | + assert result.exit_code == 0 | |
| 139 | + assert "Discover" in result.output or "models" in result.output | |
| 140 | + | |
| 141 | + | |
| 142 | +class TestClearCacheHelp: | |
| 143 | + def test_help(self): | |
| 144 | + runner = CliRunner() | |
| 145 | + result = runner.invoke(cli, ["clear-cache", "--help"]) | |
| 146 | + assert result.exit_code == 0 | |
| 147 | + assert "--cache-dir" in result.output | |
| 148 | + assert "--older-than" in result.output | |
| 149 | + assert "--all" in result.output | |
| 150 | + | |
| 151 | + | |
| 152 | +class TestGWSHelp: | |
| 153 | + def test_help(self): | |
| 154 | + runner = CliRunner() | |
| 155 | + result = runner.invoke(cli, ["gws", "--help"]) | |
| 156 | + assert result.exit_code == 0 | |
| 157 | + assert "list" in result.output | |
| 158 | + assert "fetch" in result.output | |
| 159 | + assert "ingest" in result.output | |
| 160 | + | |
| 161 | + def test_list_help(self): | |
| 162 | + runner = CliRunner() | |
| 163 | + result = runner.invoke(cli, ["gws", "list", "--help"]) | |
| 164 | + assert result.exit_code == 0 | |
| 165 | + assert "--folder-id" in result.output | |
| 166 | + assert "--query" in result.output | |
| 167 | + | |
| 168 | + def test_ingest_help(self): | |
| 169 | + runner = CliRunner() | |
| 170 | + result = runner.invoke(cli, ["gws", "ingest", "--help"]) | |
| 171 | + assert result.exit_code == 0 | |
| 172 | + assert "--folder-id" in result.output | |
| 173 | + assert "--doc-id" in result.output | |
| 174 | + assert "--db-path" in result.output | |
| 175 | + | |
| 176 | + | |
| 177 | +class TestM365Help: | |
| 178 | + def test_help(self): | |
| 179 | + runner = CliRunner() | |
| 180 | + result = runner.invoke(cli, ["m365", "--help"]) | |
| 181 | + assert result.exit_code == 0 | |
| 182 | + assert "list" in result.output | |
| 183 | + assert "fetch" in result.output | |
| 184 | + assert "ingest" in result.output | |
| 185 | + | |
| 186 | + def test_list_help(self): | |
| 187 | + runner = CliRunner() | |
| 188 | + result = runner.invoke(cli, ["m365", "list", "--help"]) | |
| 189 | + assert result.exit_code == 0 | |
| 190 | + assert "--web-url" in result.output | |
| 191 | + assert "--folder-url" in result.output | |
| 192 | + assert "--recursive" in result.output | |
| 193 | + | |
| 194 | + def test_ingest_help(self): | |
| 195 | + runner = CliRunner() | |
| 196 | + result = runner.invoke(cli, ["m365", "ingest", "--help"]) | |
| 197 | + assert result.exit_code == 0 | |
| 198 | + assert "--web-url" in result.output | |
| 199 | + assert "--file-id" in result.output | |
| 200 | + assert "--db-path" in result.output | |
| 201 | + | |
| 202 | + | |
| 203 | +class TestRecordingsHelp: | |
| 204 | + def test_group_help(self): | |
| 205 | + runner = CliRunner() | |
| 206 | + result = runner.invoke(cli, ["recordings", "--help"]) | |
| 207 | + assert result.exit_code == 0 | |
| 208 | + assert "zoom-list" in result.output | |
| 209 | + assert "teams-list" in result.output | |
| 210 | + assert "meet-list" in result.output | |
| 211 | + | |
| 212 | + def test_zoom_list_help(self): | |
| 213 | + runner = CliRunner() | |
| 214 | + result = runner.invoke(cli, ["recordings", "zoom-list", "--help"]) | |
| 215 | + assert result.exit_code == 0 | |
| 216 | + assert "ZOOM_CLIENT_ID" in result.output | |
| 217 | + | |
| 218 | + def test_teams_list_help(self): | |
| 219 | + runner = CliRunner() | |
| 220 | + result = runner.invoke(cli, ["recordings", "teams-list", "--help"]) | |
| 221 | + assert result.exit_code == 0 | |
| 222 | + assert "--user-id" in result.output | |
| 223 | + | |
| 224 | + def test_meet_list_help(self): | |
| 225 | + runner = CliRunner() | |
| 226 | + result = runner.invoke(cli, ["recordings", "meet-list", "--help"]) | |
| 227 | + assert result.exit_code == 0 | |
| 228 | + assert "--folder-id" in result.output | |
| 229 | + | |
| 230 | + | |
| 231 | +class TestAuthHelp: | |
| 232 | + def test_help(self): | |
| 233 | + runner = CliRunner() | |
| 234 | + result = runner.invoke(cli, ["auth", "--help"]) | |
| 235 | + assert result.exit_code == 0 | |
| 236 | + assert "google" in result.output | |
| 237 | + assert "dropbox" in result.output | |
| 238 | + assert "zoom" in result.output | |
| 239 | + assert "notion" in result.output | |
| 240 | + assert "github" in result.output | |
| 241 | + assert "microsoft" in result.output | |
| 242 | + assert "--logout" in result.output | |
| 243 | + | |
| 244 | + | |
| 245 | +class TestCompanionHelp: | |
| 246 | + def test_help(self): | |
| 247 | + runner = CliRunner() | |
| 248 | + result = runner.invoke(cli, ["companion", "--help"]) | |
| 249 | + assert result.exit_code == 0 | |
| 250 | + assert "--kb" in result.output | |
| 251 | + assert "--provider" in result.output | |
| 252 | + assert "--chat-model" in result.output |
| --- a/tests/test_cli.py | |
| +++ b/tests/test_cli.py | |
| @@ -0,0 +1,252 @@ | |
| --- a/tests/test_cli.py | |
| +++ b/tests/test_cli.py | |
| @@ -0,0 +1,252 @@ | |
| 1 | """Tests for the CLI commands (help text, version, option presence).""" |
| 2 | |
| 3 | from click.testing import CliRunner |
| 4 | |
| 5 | from video_processor.cli.commands import cli |
| 6 | |
| 7 | |
| 8 | class TestCLIRoot: |
| 9 | def test_version(self): |
| 10 | runner = CliRunner() |
| 11 | result = runner.invoke(cli, ["--version"]) |
| 12 | assert result.exit_code == 0 |
| 13 | assert "PlanOpticon" in result.output |
| 14 | assert "0.4.0" in result.output # matches @click.version_option |
| 15 | |
| 16 | def test_help(self): |
| 17 | runner = CliRunner() |
| 18 | result = runner.invoke(cli, ["--help"]) |
| 19 | assert result.exit_code == 0 |
| 20 | assert "PlanOpticon" in result.output |
| 21 | assert "analyze" in result.output |
| 22 | assert "query" in result.output |
| 23 | assert "agent" in result.output |
| 24 | assert "kg" in result.output |
| 25 | assert "gws" in result.output |
| 26 | assert "m365" in result.output |
| 27 | assert "recordings" in result.output |
| 28 | assert "ingest" in result.output |
| 29 | assert "batch" in result.output |
| 30 | assert "--chat" in result.output |
| 31 | assert "--interactive" in result.output |
| 32 | assert "companion" in result.output |
| 33 | |
| 34 | |
| 35 | class TestAnalyzeHelp: |
| 36 | def test_help(self): |
| 37 | runner = CliRunner() |
| 38 | result = runner.invoke(cli, ["analyze", "--help"]) |
| 39 | assert result.exit_code == 0 |
| 40 | assert "--input" in result.output or "-i" in result.output |
| 41 | assert "--output" in result.output or "-o" in result.output |
| 42 | assert "--depth" in result.output |
| 43 | assert "--provider" in result.output |
| 44 | assert "--output-format" in result.output |
| 45 | assert "--templates-dir" in result.output |
| 46 | assert "--speakers" in result.output |
| 47 | assert "--vision-model" in result.output |
| 48 | assert "--chat-model" in result.output |
| 49 | assert "--sampling-rate" in result.output |
| 50 | assert "--change-threshold" in result.output |
| 51 | assert "--periodic-capture" in result.output |
| 52 | |
| 53 | |
| 54 | class TestQueryHelp: |
| 55 | def test_help(self): |
| 56 | runner = CliRunner() |
| 57 | result = runner.invoke(cli, ["query", "--help"]) |
| 58 | assert result.exit_code == 0 |
| 59 | assert "--db-path" in result.output |
| 60 | assert "--mode" in result.output |
| 61 | assert "--format" in result.output |
| 62 | assert "--interactive" in result.output or "-I" in result.output |
| 63 | assert "--provider" in result.output |
| 64 | assert "--chat-model" in result.output |
| 65 | assert "QUESTION" in result.output |
| 66 | |
| 67 | |
| 68 | class TestAgentHelp: |
| 69 | def test_help(self): |
| 70 | runner = CliRunner() |
| 71 | result = runner.invoke(cli, ["agent", "--help"]) |
| 72 | assert result.exit_code == 0 |
| 73 | assert "--kb" in result.output |
| 74 | assert "--interactive" in result.output or "-I" in result.output |
| 75 | assert "--export" in result.output |
| 76 | assert "--provider" in result.output |
| 77 | assert "--chat-model" in result.output |
| 78 | assert "REQUEST" in result.output |
| 79 | |
| 80 | |
| 81 | class TestKGHelp: |
| 82 | def test_help(self): |
| 83 | runner = CliRunner() |
| 84 | result = runner.invoke(cli, ["kg", "--help"]) |
| 85 | assert result.exit_code == 0 |
| 86 | assert "convert" in result.output |
| 87 | assert "Knowledge graph" in result.output |
| 88 | |
| 89 | def test_convert_help(self): |
| 90 | runner = CliRunner() |
| 91 | result = runner.invoke(cli, ["kg", "convert", "--help"]) |
| 92 | assert result.exit_code == 0 |
| 93 | assert "SOURCE_PATH" in result.output |
| 94 | assert "DEST_PATH" in result.output |
| 95 | |
| 96 | |
| 97 | class TestIngestHelp: |
| 98 | def test_help(self): |
| 99 | runner = CliRunner() |
| 100 | result = runner.invoke(cli, ["ingest", "--help"]) |
| 101 | assert result.exit_code == 0 |
| 102 | assert "INPUT_PATH" in result.output |
| 103 | assert "--output" in result.output or "-o" in result.output |
| 104 | assert "--db-path" in result.output |
| 105 | assert "--recursive" in result.output |
| 106 | assert "--provider" in result.output |
| 107 | assert "--chat-model" in result.output |
| 108 | |
| 109 | |
| 110 | class TestBatchHelp: |
| 111 | def test_help(self): |
| 112 | runner = CliRunner() |
| 113 | result = runner.invoke(cli, ["batch", "--help"]) |
| 114 | assert result.exit_code == 0 |
| 115 | assert "--input-dir" in result.output or "-i" in result.output |
| 116 | assert "--output" in result.output or "-o" in result.output |
| 117 | assert "--depth" in result.output |
| 118 | assert "--pattern" in result.output |
| 119 | assert "--source" in result.output |
| 120 | assert "--folder-id" in result.output |
| 121 | |
| 122 | |
| 123 | class TestAgentAnalyzeHelp: |
| 124 | def test_help(self): |
| 125 | runner = CliRunner() |
| 126 | result = runner.invoke(cli, ["agent-analyze", "--help"]) |
| 127 | assert result.exit_code == 0 |
| 128 | assert "--input" in result.output or "-i" in result.output |
| 129 | assert "--output" in result.output or "-o" in result.output |
| 130 | assert "--depth" in result.output |
| 131 | assert "--provider" in result.output |
| 132 | |
| 133 | |
| 134 | class TestListModelsHelp: |
| 135 | def test_help(self): |
| 136 | runner = CliRunner() |
| 137 | result = runner.invoke(cli, ["list-models", "--help"]) |
| 138 | assert result.exit_code == 0 |
| 139 | assert "Discover" in result.output or "models" in result.output |
| 140 | |
| 141 | |
| 142 | class TestClearCacheHelp: |
| 143 | def test_help(self): |
| 144 | runner = CliRunner() |
| 145 | result = runner.invoke(cli, ["clear-cache", "--help"]) |
| 146 | assert result.exit_code == 0 |
| 147 | assert "--cache-dir" in result.output |
| 148 | assert "--older-than" in result.output |
| 149 | assert "--all" in result.output |
| 150 | |
| 151 | |
| 152 | class TestGWSHelp: |
| 153 | def test_help(self): |
| 154 | runner = CliRunner() |
| 155 | result = runner.invoke(cli, ["gws", "--help"]) |
| 156 | assert result.exit_code == 0 |
| 157 | assert "list" in result.output |
| 158 | assert "fetch" in result.output |
| 159 | assert "ingest" in result.output |
| 160 | |
| 161 | def test_list_help(self): |
| 162 | runner = CliRunner() |
| 163 | result = runner.invoke(cli, ["gws", "list", "--help"]) |
| 164 | assert result.exit_code == 0 |
| 165 | assert "--folder-id" in result.output |
| 166 | assert "--query" in result.output |
| 167 | |
| 168 | def test_ingest_help(self): |
| 169 | runner = CliRunner() |
| 170 | result = runner.invoke(cli, ["gws", "ingest", "--help"]) |
| 171 | assert result.exit_code == 0 |
| 172 | assert "--folder-id" in result.output |
| 173 | assert "--doc-id" in result.output |
| 174 | assert "--db-path" in result.output |
| 175 | |
| 176 | |
| 177 | class TestM365Help: |
| 178 | def test_help(self): |
| 179 | runner = CliRunner() |
| 180 | result = runner.invoke(cli, ["m365", "--help"]) |
| 181 | assert result.exit_code == 0 |
| 182 | assert "list" in result.output |
| 183 | assert "fetch" in result.output |
| 184 | assert "ingest" in result.output |
| 185 | |
| 186 | def test_list_help(self): |
| 187 | runner = CliRunner() |
| 188 | result = runner.invoke(cli, ["m365", "list", "--help"]) |
| 189 | assert result.exit_code == 0 |
| 190 | assert "--web-url" in result.output |
| 191 | assert "--folder-url" in result.output |
| 192 | assert "--recursive" in result.output |
| 193 | |
| 194 | def test_ingest_help(self): |
| 195 | runner = CliRunner() |
| 196 | result = runner.invoke(cli, ["m365", "ingest", "--help"]) |
| 197 | assert result.exit_code == 0 |
| 198 | assert "--web-url" in result.output |
| 199 | assert "--file-id" in result.output |
| 200 | assert "--db-path" in result.output |
| 201 | |
| 202 | |
| 203 | class TestRecordingsHelp: |
| 204 | def test_group_help(self): |
| 205 | runner = CliRunner() |
| 206 | result = runner.invoke(cli, ["recordings", "--help"]) |
| 207 | assert result.exit_code == 0 |
| 208 | assert "zoom-list" in result.output |
| 209 | assert "teams-list" in result.output |
| 210 | assert "meet-list" in result.output |
| 211 | |
| 212 | def test_zoom_list_help(self): |
| 213 | runner = CliRunner() |
| 214 | result = runner.invoke(cli, ["recordings", "zoom-list", "--help"]) |
| 215 | assert result.exit_code == 0 |
| 216 | assert "ZOOM_CLIENT_ID" in result.output |
| 217 | |
| 218 | def test_teams_list_help(self): |
| 219 | runner = CliRunner() |
| 220 | result = runner.invoke(cli, ["recordings", "teams-list", "--help"]) |
| 221 | assert result.exit_code == 0 |
| 222 | assert "--user-id" in result.output |
| 223 | |
| 224 | def test_meet_list_help(self): |
| 225 | runner = CliRunner() |
| 226 | result = runner.invoke(cli, ["recordings", "meet-list", "--help"]) |
| 227 | assert result.exit_code == 0 |
| 228 | assert "--folder-id" in result.output |
| 229 | |
| 230 | |
| 231 | class TestAuthHelp: |
| 232 | def test_help(self): |
| 233 | runner = CliRunner() |
| 234 | result = runner.invoke(cli, ["auth", "--help"]) |
| 235 | assert result.exit_code == 0 |
| 236 | assert "google" in result.output |
| 237 | assert "dropbox" in result.output |
| 238 | assert "zoom" in result.output |
| 239 | assert "notion" in result.output |
| 240 | assert "github" in result.output |
| 241 | assert "microsoft" in result.output |
| 242 | assert "--logout" in result.output |
| 243 | |
| 244 | |
| 245 | class TestCompanionHelp: |
| 246 | def test_help(self): |
| 247 | runner = CliRunner() |
| 248 | result = runner.invoke(cli, ["companion", "--help"]) |
| 249 | assert result.exit_code == 0 |
| 250 | assert "--kb" in result.output |
| 251 | assert "--provider" in result.output |
| 252 | assert "--chat-model" in result.output |
+162
| --- a/tests/test_companion.py | ||
| +++ b/tests/test_companion.py | ||
| @@ -0,0 +1,162 @@ | ||
| 1 | +"""Tests for the CompanionREPL (without launching the loop).""" | |
| 2 | + | |
| 3 | +from unittest.mock import patch | |
| 4 | + | |
| 5 | +from video_processor.cli.companion import CompanionREPL | |
| 6 | + | |
| 7 | + | |
| 8 | +class TestImport: | |
| 9 | + def test_import(self): | |
| 10 | + from video_processor.cli import companion # noqa: F401 | |
| 11 | + | |
| 12 | + assert hasattr(companion, "CompanionREPL") | |
| 13 | + | |
| 14 | + | |
| 15 | +class TestConstructor: | |
| 16 | + def test_defaults(self): | |
| 17 | + repl = CompanionREPL() | |
| 18 | + assert repl.kg is None | |
| 19 | + assert repl.query_engine is None | |
| 20 | + assert repl.agent is None | |
| 21 | + assert repl.provider_manager is None | |
| 22 | + | |
| 23 | + def test_explicit_args(self): | |
| 24 | + repl = CompanionREPL( | |
| 25 | + kb_paths=["/tmp/fake.db"], | |
| 26 | + provider="openai", | |
| 27 | + chat_model="gpt-4", | |
| 28 | + ) | |
| 29 | + assert repl._kb_paths == ["/tmp/fake.db"] | |
| 30 | + assert repl._provider_name == "openai" | |
| 31 | + assert repl._chat_model == "gpt-4" | |
| 32 | + | |
| 33 | + | |
| 34 | +class TestAutoDiscovery: | |
| 35 | + @patch( | |
| 36 | + "video_processor.integrators.graph_discovery.find_nearest_graph", | |
| 37 | + return_value=None, | |
| 38 | + ) | |
| 39 | + def test_no_graph_found(self, mock_find): | |
| 40 | + repl = CompanionREPL() | |
| 41 | + repl._discover() | |
| 42 | + assert repl.query_engine is None | |
| 43 | + assert repl.kg is None | |
| 44 | + mock_find.assert_called_once() | |
| 45 | + | |
| 46 | + | |
| 47 | +class TestHandleHelp: | |
| 48 | + def test_handle_help(self): | |
| 49 | + repl = CompanionREPL() | |
| 50 | + output = repl.handle_input("/help") | |
| 51 | + assert "Available commands" in output | |
| 52 | + assert "/status" in output | |
| 53 | + assert "/skills" in output | |
| 54 | + assert "/entities" in output | |
| 55 | + assert "/quit" in output | |
| 56 | + | |
| 57 | + | |
| 58 | +class TestHandleStatus: | |
| 59 | + def test_handle_status_no_kg(self): | |
| 60 | + repl = CompanionREPL() | |
| 61 | + output = repl.handle_input("/status") | |
| 62 | + assert "Workspace status" in output | |
| 63 | + assert "not loaded" in output | |
| 64 | + | |
| 65 | + | |
| 66 | +class TestHandleSkills: | |
| 67 | + def test_handle_skills(self): | |
| 68 | + repl = CompanionREPL() | |
| 69 | + output = repl.handle_input("/skills") | |
| 70 | + # Either lists skills or says none registered | |
| 71 | + assert "skills" in output.lower() or "No skills" in output | |
| 72 | + | |
| 73 | + | |
| 74 | +class TestHandleQuit: | |
| 75 | + def test_quit(self): | |
| 76 | + repl = CompanionREPL() | |
| 77 | + assert repl.handle_input("/quit") == "__QUIT__" | |
| 78 | + | |
| 79 | + def test_exit(self): | |
| 80 | + repl = CompanionREPL() | |
| 81 | + assert repl.handle_input("/exit") == "__QUIT__" | |
| 82 | + | |
| 83 | + def test_bare_quit(self): | |
| 84 | + repl = CompanionREPL() | |
| 85 | + assert repl.handle_input("quit") == "__QUIT__" | |
| 86 | + | |
| 87 | + def test_bare_exit(self): | |
| 88 | + repl = CompanionREPL() | |
| 89 | + assert repl.handle_input("exit") == "__QUIT__" | |
| 90 | + | |
| 91 | + def test_bare_bye(self): | |
| 92 | + repl = CompanionREPL() | |
| 93 | + assert repl.handle_input("bye") == "__QUIT__" | |
| 94 | + | |
| 95 | + def test_bare_q(self): | |
| 96 | + repl = CompanionREPL() | |
| 97 | + assert repl.handle_input("q") == "__QUIT__" | |
| 98 | + | |
| 99 | + | |
| 100 | +class TestHandleUnknownSlash: | |
| 101 | + def test_unknown_command(self): | |
| 102 | + repl = CompanionREPL() | |
| 103 | + output = repl.handle_input("/foobar") | |
| 104 | + assert "Unknown command" in output | |
| 105 | + assert "/help" in output | |
| 106 | + | |
| 107 | + | |
| 108 | +class TestHandleChatNoProvider: | |
| 109 | + def test_chat_no_provider(self): | |
| 110 | + repl = CompanionREPL() | |
| 111 | + output = repl.handle_input("What is this project about?") | |
| 112 | + assert "LLM provider" in output or "API" in output | |
| 113 | + # Should not crash | |
| 114 | + | |
| 115 | + | |
| 116 | +class TestHandleEntitiesNoKG: | |
| 117 | + def test_entities_no_kg(self): | |
| 118 | + repl = CompanionREPL() | |
| 119 | + output = repl.handle_input("/entities") | |
| 120 | + assert "No knowledge graph loaded" in output | |
| 121 | + | |
| 122 | + def test_search_no_kg(self): | |
| 123 | + repl = CompanionREPL() | |
| 124 | + output = repl.handle_input("/search python") | |
| 125 | + assert "No knowledge graph loaded" in output | |
| 126 | + | |
| 127 | + def test_neighbors_no_kg(self): | |
| 128 | + repl = CompanionREPL() | |
| 129 | + output = repl.handle_input("/neighbors Alice") | |
| 130 | + assert "No knowledge graph loaded" in output | |
| 131 | + | |
| 132 | + | |
| 133 | +class TestProviderCommand: | |
| 134 | + def test_provider_list(self): | |
| 135 | + repl = CompanionREPL() | |
| 136 | + output = repl.handle_input("/provider") | |
| 137 | + assert "Available providers" in output | |
| 138 | + assert "openai" in output | |
| 139 | + assert "anthropic" in output | |
| 140 | + | |
| 141 | + def test_provider_switch(self): | |
| 142 | + repl = CompanionREPL() | |
| 143 | + output = repl.handle_input("/provider openai") | |
| 144 | + # Will fail to init without key, but shouldn't crash | |
| 145 | + assert "openai" in output.lower() | |
| 146 | + | |
| 147 | + def test_model_show(self): | |
| 148 | + repl = CompanionREPL() | |
| 149 | + output = repl.handle_input("/model") | |
| 150 | + assert "Current model" in output | |
| 151 | + | |
| 152 | + def test_model_switch(self): | |
| 153 | + repl = CompanionREPL() | |
| 154 | + output = repl.handle_input("/model gpt-4o") | |
| 155 | + # Will fail without provider, but shouldn't crash | |
| 156 | + assert "gpt-4o" in output | |
| 157 | + | |
| 158 | + def test_help_includes_provider(self): | |
| 159 | + repl = CompanionREPL() | |
| 160 | + output = repl.handle_input("/help") | |
| 161 | + assert "/provider" in output | |
| 162 | + assert "/model" in output |
| --- a/tests/test_companion.py | |
| +++ b/tests/test_companion.py | |
| @@ -0,0 +1,162 @@ | |
| --- a/tests/test_companion.py | |
| +++ b/tests/test_companion.py | |
| @@ -0,0 +1,162 @@ | |
| 1 | """Tests for the CompanionREPL (without launching the loop).""" |
| 2 | |
| 3 | from unittest.mock import patch |
| 4 | |
| 5 | from video_processor.cli.companion import CompanionREPL |
| 6 | |
| 7 | |
| 8 | class TestImport: |
| 9 | def test_import(self): |
| 10 | from video_processor.cli import companion # noqa: F401 |
| 11 | |
| 12 | assert hasattr(companion, "CompanionREPL") |
| 13 | |
| 14 | |
| 15 | class TestConstructor: |
| 16 | def test_defaults(self): |
| 17 | repl = CompanionREPL() |
| 18 | assert repl.kg is None |
| 19 | assert repl.query_engine is None |
| 20 | assert repl.agent is None |
| 21 | assert repl.provider_manager is None |
| 22 | |
| 23 | def test_explicit_args(self): |
| 24 | repl = CompanionREPL( |
| 25 | kb_paths=["/tmp/fake.db"], |
| 26 | provider="openai", |
| 27 | chat_model="gpt-4", |
| 28 | ) |
| 29 | assert repl._kb_paths == ["/tmp/fake.db"] |
| 30 | assert repl._provider_name == "openai" |
| 31 | assert repl._chat_model == "gpt-4" |
| 32 | |
| 33 | |
| 34 | class TestAutoDiscovery: |
| 35 | @patch( |
| 36 | "video_processor.integrators.graph_discovery.find_nearest_graph", |
| 37 | return_value=None, |
| 38 | ) |
| 39 | def test_no_graph_found(self, mock_find): |
| 40 | repl = CompanionREPL() |
| 41 | repl._discover() |
| 42 | assert repl.query_engine is None |
| 43 | assert repl.kg is None |
| 44 | mock_find.assert_called_once() |
| 45 | |
| 46 | |
| 47 | class TestHandleHelp: |
| 48 | def test_handle_help(self): |
| 49 | repl = CompanionREPL() |
| 50 | output = repl.handle_input("/help") |
| 51 | assert "Available commands" in output |
| 52 | assert "/status" in output |
| 53 | assert "/skills" in output |
| 54 | assert "/entities" in output |
| 55 | assert "/quit" in output |
| 56 | |
| 57 | |
| 58 | class TestHandleStatus: |
| 59 | def test_handle_status_no_kg(self): |
| 60 | repl = CompanionREPL() |
| 61 | output = repl.handle_input("/status") |
| 62 | assert "Workspace status" in output |
| 63 | assert "not loaded" in output |
| 64 | |
| 65 | |
| 66 | class TestHandleSkills: |
| 67 | def test_handle_skills(self): |
| 68 | repl = CompanionREPL() |
| 69 | output = repl.handle_input("/skills") |
| 70 | # Either lists skills or says none registered |
| 71 | assert "skills" in output.lower() or "No skills" in output |
| 72 | |
| 73 | |
| 74 | class TestHandleQuit: |
| 75 | def test_quit(self): |
| 76 | repl = CompanionREPL() |
| 77 | assert repl.handle_input("/quit") == "__QUIT__" |
| 78 | |
| 79 | def test_exit(self): |
| 80 | repl = CompanionREPL() |
| 81 | assert repl.handle_input("/exit") == "__QUIT__" |
| 82 | |
| 83 | def test_bare_quit(self): |
| 84 | repl = CompanionREPL() |
| 85 | assert repl.handle_input("quit") == "__QUIT__" |
| 86 | |
| 87 | def test_bare_exit(self): |
| 88 | repl = CompanionREPL() |
| 89 | assert repl.handle_input("exit") == "__QUIT__" |
| 90 | |
| 91 | def test_bare_bye(self): |
| 92 | repl = CompanionREPL() |
| 93 | assert repl.handle_input("bye") == "__QUIT__" |
| 94 | |
| 95 | def test_bare_q(self): |
| 96 | repl = CompanionREPL() |
| 97 | assert repl.handle_input("q") == "__QUIT__" |
| 98 | |
| 99 | |
| 100 | class TestHandleUnknownSlash: |
| 101 | def test_unknown_command(self): |
| 102 | repl = CompanionREPL() |
| 103 | output = repl.handle_input("/foobar") |
| 104 | assert "Unknown command" in output |
| 105 | assert "/help" in output |
| 106 | |
| 107 | |
| 108 | class TestHandleChatNoProvider: |
| 109 | def test_chat_no_provider(self): |
| 110 | repl = CompanionREPL() |
| 111 | output = repl.handle_input("What is this project about?") |
| 112 | assert "LLM provider" in output or "API" in output |
| 113 | # Should not crash |
| 114 | |
| 115 | |
| 116 | class TestHandleEntitiesNoKG: |
| 117 | def test_entities_no_kg(self): |
| 118 | repl = CompanionREPL() |
| 119 | output = repl.handle_input("/entities") |
| 120 | assert "No knowledge graph loaded" in output |
| 121 | |
| 122 | def test_search_no_kg(self): |
| 123 | repl = CompanionREPL() |
| 124 | output = repl.handle_input("/search python") |
| 125 | assert "No knowledge graph loaded" in output |
| 126 | |
| 127 | def test_neighbors_no_kg(self): |
| 128 | repl = CompanionREPL() |
| 129 | output = repl.handle_input("/neighbors Alice") |
| 130 | assert "No knowledge graph loaded" in output |
| 131 | |
| 132 | |
| 133 | class TestProviderCommand: |
| 134 | def test_provider_list(self): |
| 135 | repl = CompanionREPL() |
| 136 | output = repl.handle_input("/provider") |
| 137 | assert "Available providers" in output |
| 138 | assert "openai" in output |
| 139 | assert "anthropic" in output |
| 140 | |
| 141 | def test_provider_switch(self): |
| 142 | repl = CompanionREPL() |
| 143 | output = repl.handle_input("/provider openai") |
| 144 | # Will fail to init without key, but shouldn't crash |
| 145 | assert "openai" in output.lower() |
| 146 | |
| 147 | def test_model_show(self): |
| 148 | repl = CompanionREPL() |
| 149 | output = repl.handle_input("/model") |
| 150 | assert "Current model" in output |
| 151 | |
| 152 | def test_model_switch(self): |
| 153 | repl = CompanionREPL() |
| 154 | output = repl.handle_input("/model gpt-4o") |
| 155 | # Will fail without provider, but shouldn't crash |
| 156 | assert "gpt-4o" in output |
| 157 | |
| 158 | def test_help_includes_provider(self): |
| 159 | repl = CompanionREPL() |
| 160 | output = repl.handle_input("/help") |
| 161 | assert "/provider" in output |
| 162 | assert "/model" in output |
+224
| --- a/tests/test_exchange.py | ||
| +++ b/tests/test_exchange.py | ||
| @@ -0,0 +1,224 @@ | ||
| 1 | +"""Tests for the PlanOpticonExchange interchange format.""" | |
| 2 | + | |
| 3 | +import json | |
| 4 | + | |
| 5 | +from video_processor.exchange import ( | |
| 6 | + ArtifactMeta, | |
| 7 | + PlanOpticonExchange, | |
| 8 | + ProjectMeta, | |
| 9 | +) | |
| 10 | +from video_processor.models import Entity, Relationship, SourceRecord | |
| 11 | + | |
| 12 | +# ------------------------------------------------------------------ | |
| 13 | +# Fixtures | |
| 14 | +# ------------------------------------------------------------------ | |
| 15 | + | |
| 16 | + | |
| 17 | +def _sample_entity(name: str = "Python", etype: str = "technology"): | |
| 18 | + return Entity( | |
| 19 | + name=name, | |
| 20 | + type=etype, | |
| 21 | + descriptions=["A programming language"], | |
| 22 | + ) | |
| 23 | + | |
| 24 | + | |
| 25 | +def _sample_relationship(): | |
| 26 | + return Relationship( | |
| 27 | + source="Alice", | |
| 28 | + target="Python", | |
| 29 | + type="uses", | |
| 30 | + ) | |
| 31 | + | |
| 32 | + | |
| 33 | +def _sample_source(): | |
| 34 | + return SourceRecord( | |
| 35 | + source_id="src-1", | |
| 36 | + source_type="video", | |
| 37 | + title="Intro recording", | |
| 38 | + ) | |
| 39 | + | |
| 40 | + | |
| 41 | +def _sample_artifact(): | |
| 42 | + return ArtifactMeta( | |
| 43 | + name="roadmap", | |
| 44 | + content="# Roadmap\n- Phase 1", | |
| 45 | + artifact_type="roadmap", | |
| 46 | + format="markdown", | |
| 47 | + ) | |
| 48 | + | |
| 49 | + | |
| 50 | +def _sample_project(): | |
| 51 | + return ProjectMeta(name="TestProject", description="A test") | |
| 52 | + | |
| 53 | + | |
| 54 | +# ------------------------------------------------------------------ | |
| 55 | +# Tests | |
| 56 | +# ------------------------------------------------------------------ | |
| 57 | + | |
| 58 | + | |
| 59 | +def test_create_empty_exchange(): | |
| 60 | + ex = PlanOpticonExchange(project=_sample_project()) | |
| 61 | + assert ex.version == "1.0" | |
| 62 | + assert ex.entities == [] | |
| 63 | + assert ex.relationships == [] | |
| 64 | + assert ex.artifacts == [] | |
| 65 | + assert ex.sources == [] | |
| 66 | + assert ex.project.name == "TestProject" | |
| 67 | + | |
| 68 | + | |
| 69 | +def test_create_with_data(): | |
| 70 | + ex = PlanOpticonExchange( | |
| 71 | + project=_sample_project(), | |
| 72 | + entities=[_sample_entity()], | |
| 73 | + relationships=[_sample_relationship()], | |
| 74 | + artifacts=[_sample_artifact()], | |
| 75 | + sources=[_sample_source()], | |
| 76 | + ) | |
| 77 | + assert len(ex.entities) == 1 | |
| 78 | + assert ex.entities[0].name == "Python" | |
| 79 | + assert len(ex.relationships) == 1 | |
| 80 | + assert len(ex.artifacts) == 1 | |
| 81 | + assert len(ex.sources) == 1 | |
| 82 | + | |
| 83 | + | |
| 84 | +def test_json_roundtrip(tmp_path): | |
| 85 | + original = PlanOpticonExchange( | |
| 86 | + project=_sample_project(), | |
| 87 | + entities=[_sample_entity()], | |
| 88 | + relationships=[_sample_relationship()], | |
| 89 | + artifacts=[_sample_artifact()], | |
| 90 | + sources=[_sample_source()], | |
| 91 | + ) | |
| 92 | + out = tmp_path / "exchange.json" | |
| 93 | + original.to_file(out) | |
| 94 | + | |
| 95 | + assert out.exists() | |
| 96 | + loaded = PlanOpticonExchange.from_file(out) | |
| 97 | + assert loaded.project.name == original.project.name | |
| 98 | + assert len(loaded.entities) == 1 | |
| 99 | + assert loaded.entities[0].name == "Python" | |
| 100 | + assert len(loaded.relationships) == 1 | |
| 101 | + assert len(loaded.artifacts) == 1 | |
| 102 | + assert len(loaded.sources) == 1 | |
| 103 | + | |
| 104 | + # Verify valid JSON on disk | |
| 105 | + raw = json.loads(out.read_text()) | |
| 106 | + assert raw["version"] == "1.0" | |
| 107 | + | |
| 108 | + | |
| 109 | +def test_json_schema_export(): | |
| 110 | + schema = PlanOpticonExchange.json_schema() | |
| 111 | + assert isinstance(schema, dict) | |
| 112 | + assert "properties" in schema | |
| 113 | + assert "version" in schema["properties"] | |
| 114 | + assert "project" in schema["properties"] | |
| 115 | + assert "entities" in schema["properties"] | |
| 116 | + | |
| 117 | + | |
| 118 | +def test_from_knowledge_graph(): | |
| 119 | + kg_dict = { | |
| 120 | + "nodes": [ | |
| 121 | + { | |
| 122 | + "id": "python", | |
| 123 | + "name": "Python", | |
| 124 | + "type": "technology", | |
| 125 | + "descriptions": ["A language"], | |
| 126 | + "occurrences": [], | |
| 127 | + }, | |
| 128 | + { | |
| 129 | + "id": "alice", | |
| 130 | + "name": "Alice", | |
| 131 | + "type": "person", | |
| 132 | + "descriptions": ["Engineer"], | |
| 133 | + "occurrences": [], | |
| 134 | + }, | |
| 135 | + ], | |
| 136 | + "relationships": [ | |
| 137 | + { | |
| 138 | + "source": "Alice", | |
| 139 | + "target": "Python", | |
| 140 | + "type": "uses", | |
| 141 | + }, | |
| 142 | + ], | |
| 143 | + "sources": [ | |
| 144 | + { | |
| 145 | + "source_id": "s1", | |
| 146 | + "source_type": "video", | |
| 147 | + "title": "Recording", | |
| 148 | + }, | |
| 149 | + ], | |
| 150 | + } | |
| 151 | + | |
| 152 | + ex = PlanOpticonExchange.from_knowledge_graph( | |
| 153 | + kg_dict, | |
| 154 | + project_name="Demo", | |
| 155 | + tags=["test"], | |
| 156 | + ) | |
| 157 | + assert ex.project.name == "Demo" | |
| 158 | + assert len(ex.entities) == 2 | |
| 159 | + assert len(ex.relationships) == 1 | |
| 160 | + assert len(ex.sources) == 1 | |
| 161 | + assert "test" in ex.project.tags | |
| 162 | + | |
| 163 | + | |
| 164 | +def test_merge_deduplicates_entities(): | |
| 165 | + ex1 = PlanOpticonExchange( | |
| 166 | + project=_sample_project(), | |
| 167 | + entities=[_sample_entity("Python"), _sample_entity("Rust")], | |
| 168 | + relationships=[_sample_relationship()], | |
| 169 | + sources=[_sample_source()], | |
| 170 | + ) | |
| 171 | + ex2 = PlanOpticonExchange( | |
| 172 | + project=ProjectMeta(name="Other"), | |
| 173 | + entities=[ | |
| 174 | + _sample_entity("Python"), # duplicate | |
| 175 | + _sample_entity("Go"), # new | |
| 176 | + ], | |
| 177 | + relationships=[ | |
| 178 | + Relationship(source="Bob", target="Go", type="uses"), | |
| 179 | + ], | |
| 180 | + sources=[ | |
| 181 | + SourceRecord( | |
| 182 | + source_id="src-2", | |
| 183 | + source_type="document", | |
| 184 | + title="Notes", | |
| 185 | + ), | |
| 186 | + ], | |
| 187 | + ) | |
| 188 | + | |
| 189 | + ex1.merge(ex2) | |
| 190 | + | |
| 191 | + entity_names = [e.name for e in ex1.entities] | |
| 192 | + assert entity_names.count("Python") == 1 | |
| 193 | + assert "Go" in entity_names | |
| 194 | + assert "Rust" in entity_names | |
| 195 | + assert len(ex1.entities) == 3 | |
| 196 | + assert len(ex1.relationships) == 2 | |
| 197 | + assert len(ex1.sources) == 2 | |
| 198 | + | |
| 199 | + | |
| 200 | +def test_version_field(): | |
| 201 | + ex = PlanOpticonExchange( | |
| 202 | + version="2.0", | |
| 203 | + project=_sample_project(), | |
| 204 | + ) | |
| 205 | + assert ex.version == "2.0" | |
| 206 | + | |
| 207 | + | |
| 208 | +def test_artifact_meta_model(): | |
| 209 | + art = ArtifactMeta( | |
| 210 | + name="plan", | |
| 211 | + content="# Plan\nDo stuff", | |
| 212 | + artifact_type="project_plan", | |
| 213 | + format="markdown", | |
| 214 | + metadata={"author": "agent"}, | |
| 215 | + ) | |
| 216 | + assert art.name == "plan" | |
| 217 | + assert art.artifact_type == "project_plan" | |
| 218 | + assert art.format == "markdown" | |
| 219 | + assert art.metadata == {"author": "agent"} | |
| 220 | + | |
| 221 | + # Roundtrip via dict | |
| 222 | + d = art.model_dump() | |
| 223 | + restored = ArtifactMeta.model_validate(d) | |
| 224 | + assert restored == art |
| --- a/tests/test_exchange.py | |
| +++ b/tests/test_exchange.py | |
| @@ -0,0 +1,224 @@ | |
| --- a/tests/test_exchange.py | |
| +++ b/tests/test_exchange.py | |
| @@ -0,0 +1,224 @@ | |
| 1 | """Tests for the PlanOpticonExchange interchange format.""" |
| 2 | |
| 3 | import json |
| 4 | |
| 5 | from video_processor.exchange import ( |
| 6 | ArtifactMeta, |
| 7 | PlanOpticonExchange, |
| 8 | ProjectMeta, |
| 9 | ) |
| 10 | from video_processor.models import Entity, Relationship, SourceRecord |
| 11 | |
| 12 | # ------------------------------------------------------------------ |
| 13 | # Fixtures |
| 14 | # ------------------------------------------------------------------ |
| 15 | |
| 16 | |
| 17 | def _sample_entity(name: str = "Python", etype: str = "technology"): |
| 18 | return Entity( |
| 19 | name=name, |
| 20 | type=etype, |
| 21 | descriptions=["A programming language"], |
| 22 | ) |
| 23 | |
| 24 | |
| 25 | def _sample_relationship(): |
| 26 | return Relationship( |
| 27 | source="Alice", |
| 28 | target="Python", |
| 29 | type="uses", |
| 30 | ) |
| 31 | |
| 32 | |
| 33 | def _sample_source(): |
| 34 | return SourceRecord( |
| 35 | source_id="src-1", |
| 36 | source_type="video", |
| 37 | title="Intro recording", |
| 38 | ) |
| 39 | |
| 40 | |
| 41 | def _sample_artifact(): |
| 42 | return ArtifactMeta( |
| 43 | name="roadmap", |
| 44 | content="# Roadmap\n- Phase 1", |
| 45 | artifact_type="roadmap", |
| 46 | format="markdown", |
| 47 | ) |
| 48 | |
| 49 | |
| 50 | def _sample_project(): |
| 51 | return ProjectMeta(name="TestProject", description="A test") |
| 52 | |
| 53 | |
| 54 | # ------------------------------------------------------------------ |
| 55 | # Tests |
| 56 | # ------------------------------------------------------------------ |
| 57 | |
| 58 | |
| 59 | def test_create_empty_exchange(): |
| 60 | ex = PlanOpticonExchange(project=_sample_project()) |
| 61 | assert ex.version == "1.0" |
| 62 | assert ex.entities == [] |
| 63 | assert ex.relationships == [] |
| 64 | assert ex.artifacts == [] |
| 65 | assert ex.sources == [] |
| 66 | assert ex.project.name == "TestProject" |
| 67 | |
| 68 | |
| 69 | def test_create_with_data(): |
| 70 | ex = PlanOpticonExchange( |
| 71 | project=_sample_project(), |
| 72 | entities=[_sample_entity()], |
| 73 | relationships=[_sample_relationship()], |
| 74 | artifacts=[_sample_artifact()], |
| 75 | sources=[_sample_source()], |
| 76 | ) |
| 77 | assert len(ex.entities) == 1 |
| 78 | assert ex.entities[0].name == "Python" |
| 79 | assert len(ex.relationships) == 1 |
| 80 | assert len(ex.artifacts) == 1 |
| 81 | assert len(ex.sources) == 1 |
| 82 | |
| 83 | |
| 84 | def test_json_roundtrip(tmp_path): |
| 85 | original = PlanOpticonExchange( |
| 86 | project=_sample_project(), |
| 87 | entities=[_sample_entity()], |
| 88 | relationships=[_sample_relationship()], |
| 89 | artifacts=[_sample_artifact()], |
| 90 | sources=[_sample_source()], |
| 91 | ) |
| 92 | out = tmp_path / "exchange.json" |
| 93 | original.to_file(out) |
| 94 | |
| 95 | assert out.exists() |
| 96 | loaded = PlanOpticonExchange.from_file(out) |
| 97 | assert loaded.project.name == original.project.name |
| 98 | assert len(loaded.entities) == 1 |
| 99 | assert loaded.entities[0].name == "Python" |
| 100 | assert len(loaded.relationships) == 1 |
| 101 | assert len(loaded.artifacts) == 1 |
| 102 | assert len(loaded.sources) == 1 |
| 103 | |
| 104 | # Verify valid JSON on disk |
| 105 | raw = json.loads(out.read_text()) |
| 106 | assert raw["version"] == "1.0" |
| 107 | |
| 108 | |
| 109 | def test_json_schema_export(): |
| 110 | schema = PlanOpticonExchange.json_schema() |
| 111 | assert isinstance(schema, dict) |
| 112 | assert "properties" in schema |
| 113 | assert "version" in schema["properties"] |
| 114 | assert "project" in schema["properties"] |
| 115 | assert "entities" in schema["properties"] |
| 116 | |
| 117 | |
| 118 | def test_from_knowledge_graph(): |
| 119 | kg_dict = { |
| 120 | "nodes": [ |
| 121 | { |
| 122 | "id": "python", |
| 123 | "name": "Python", |
| 124 | "type": "technology", |
| 125 | "descriptions": ["A language"], |
| 126 | "occurrences": [], |
| 127 | }, |
| 128 | { |
| 129 | "id": "alice", |
| 130 | "name": "Alice", |
| 131 | "type": "person", |
| 132 | "descriptions": ["Engineer"], |
| 133 | "occurrences": [], |
| 134 | }, |
| 135 | ], |
| 136 | "relationships": [ |
| 137 | { |
| 138 | "source": "Alice", |
| 139 | "target": "Python", |
| 140 | "type": "uses", |
| 141 | }, |
| 142 | ], |
| 143 | "sources": [ |
| 144 | { |
| 145 | "source_id": "s1", |
| 146 | "source_type": "video", |
| 147 | "title": "Recording", |
| 148 | }, |
| 149 | ], |
| 150 | } |
| 151 | |
| 152 | ex = PlanOpticonExchange.from_knowledge_graph( |
| 153 | kg_dict, |
| 154 | project_name="Demo", |
| 155 | tags=["test"], |
| 156 | ) |
| 157 | assert ex.project.name == "Demo" |
| 158 | assert len(ex.entities) == 2 |
| 159 | assert len(ex.relationships) == 1 |
| 160 | assert len(ex.sources) == 1 |
| 161 | assert "test" in ex.project.tags |
| 162 | |
| 163 | |
| 164 | def test_merge_deduplicates_entities(): |
| 165 | ex1 = PlanOpticonExchange( |
| 166 | project=_sample_project(), |
| 167 | entities=[_sample_entity("Python"), _sample_entity("Rust")], |
| 168 | relationships=[_sample_relationship()], |
| 169 | sources=[_sample_source()], |
| 170 | ) |
| 171 | ex2 = PlanOpticonExchange( |
| 172 | project=ProjectMeta(name="Other"), |
| 173 | entities=[ |
| 174 | _sample_entity("Python"), # duplicate |
| 175 | _sample_entity("Go"), # new |
| 176 | ], |
| 177 | relationships=[ |
| 178 | Relationship(source="Bob", target="Go", type="uses"), |
| 179 | ], |
| 180 | sources=[ |
| 181 | SourceRecord( |
| 182 | source_id="src-2", |
| 183 | source_type="document", |
| 184 | title="Notes", |
| 185 | ), |
| 186 | ], |
| 187 | ) |
| 188 | |
| 189 | ex1.merge(ex2) |
| 190 | |
| 191 | entity_names = [e.name for e in ex1.entities] |
| 192 | assert entity_names.count("Python") == 1 |
| 193 | assert "Go" in entity_names |
| 194 | assert "Rust" in entity_names |
| 195 | assert len(ex1.entities) == 3 |
| 196 | assert len(ex1.relationships) == 2 |
| 197 | assert len(ex1.sources) == 2 |
| 198 | |
| 199 | |
| 200 | def test_version_field(): |
| 201 | ex = PlanOpticonExchange( |
| 202 | version="2.0", |
| 203 | project=_sample_project(), |
| 204 | ) |
| 205 | assert ex.version == "2.0" |
| 206 | |
| 207 | |
| 208 | def test_artifact_meta_model(): |
| 209 | art = ArtifactMeta( |
| 210 | name="plan", |
| 211 | content="# Plan\nDo stuff", |
| 212 | artifact_type="project_plan", |
| 213 | format="markdown", |
| 214 | metadata={"author": "agent"}, |
| 215 | ) |
| 216 | assert art.name == "plan" |
| 217 | assert art.artifact_type == "project_plan" |
| 218 | assert art.format == "markdown" |
| 219 | assert art.metadata == {"author": "agent"} |
| 220 | |
| 221 | # Roundtrip via dict |
| 222 | d = art.model_dump() |
| 223 | restored = ArtifactMeta.model_validate(d) |
| 224 | assert restored == art |
+10
-25
| --- tests/test_graph_query.py | ||
| +++ tests/test_graph_query.py | ||
| @@ -4,11 +4,11 @@ | ||
| 4 | 4 | from unittest.mock import MagicMock |
| 5 | 5 | |
| 6 | 6 | import pytest |
| 7 | 7 | |
| 8 | 8 | from video_processor.integrators.graph_query import GraphQueryEngine, QueryResult |
| 9 | -from video_processor.integrators.graph_store import InMemoryStore | |
| 9 | +from video_processor.integrators.graph_store import InMemoryStore, SQLiteStore | |
| 10 | 10 | |
| 11 | 11 | |
| 12 | 12 | def _make_populated_store(): |
| 13 | 13 | """Create a store with test data.""" |
| 14 | 14 | store = InMemoryStore() |
| @@ -167,15 +167,15 @@ | ||
| 167 | 167 | engine = GraphQueryEngine(store) |
| 168 | 168 | result = engine.neighbors("Ghost") |
| 169 | 169 | assert result.data == [] |
| 170 | 170 | assert "not found" in result.explanation |
| 171 | 171 | |
| 172 | - def test_cypher_raises_on_inmemory(self): | |
| 172 | + def test_sql_raises_on_inmemory(self): | |
| 173 | 173 | store = InMemoryStore() |
| 174 | 174 | engine = GraphQueryEngine(store) |
| 175 | 175 | with pytest.raises(NotImplementedError): |
| 176 | - engine.cypher("MATCH (n) RETURN n") | |
| 176 | + engine.sql("SELECT * FROM entities") | |
| 177 | 177 | |
| 178 | 178 | def test_entities_limit(self): |
| 179 | 179 | store = _make_populated_store() |
| 180 | 180 | engine = GraphQueryEngine(store) |
| 181 | 181 | result = engine.entities(limit=2) |
| @@ -199,39 +199,24 @@ | ||
| 199 | 199 | result = engine.stats() |
| 200 | 200 | assert result.data["entity_count"] == 2 |
| 201 | 201 | assert result.data["relationship_count"] == 1 |
| 202 | 202 | |
| 203 | 203 | |
| 204 | -# Conditional FalkorDB tests | |
| 205 | -_falkordb_available = False | |
| 206 | -try: | |
| 207 | - import redislite # noqa: F401 | |
| 208 | - | |
| 209 | - _falkordb_available = True | |
| 210 | -except ImportError: | |
| 211 | - pass | |
| 212 | - | |
| 213 | - | |
| 214 | -@pytest.mark.skipif(not _falkordb_available, reason="falkordblite not installed") | |
| 215 | -class TestFalkorDBQuery: | |
| 216 | - def test_cypher_passthrough(self, tmp_path): | |
| 217 | - from video_processor.integrators.graph_store import FalkorDBStore | |
| 218 | - | |
| 219 | - store = FalkorDBStore(tmp_path / "test.db") | |
| 204 | +class TestSQLiteQuery: | |
| 205 | + def test_sql_passthrough(self, tmp_path): | |
| 206 | + store = SQLiteStore(tmp_path / "test.db") | |
| 220 | 207 | store.merge_entity("Python", "technology", ["A language"]) |
| 221 | 208 | engine = GraphQueryEngine(store) |
| 222 | - result = engine.cypher("MATCH (e:Entity) RETURN e.name") | |
| 209 | + result = engine.sql("SELECT name FROM entities") | |
| 223 | 210 | assert len(result.data) >= 1 |
| 224 | - assert result.query_type == "cypher" | |
| 211 | + assert result.query_type == "sql" | |
| 225 | 212 | store.close() |
| 226 | 213 | |
| 227 | 214 | def test_raw_query_on_store(self, tmp_path): |
| 228 | - from video_processor.integrators.graph_store import FalkorDBStore | |
| 229 | - | |
| 230 | - store = FalkorDBStore(tmp_path / "test.db") | |
| 215 | + store = SQLiteStore(tmp_path / "test.db") | |
| 231 | 216 | store.merge_entity("Alice", "person", ["Engineer"]) |
| 232 | - rows = store.raw_query("MATCH (e:Entity) RETURN e.name") | |
| 217 | + rows = store.raw_query("SELECT name FROM entities") | |
| 233 | 218 | assert len(rows) >= 1 |
| 234 | 219 | store.close() |
| 235 | 220 | |
| 236 | 221 | |
| 237 | 222 | class TestAgenticMode: |
| 238 | 223 |
| --- tests/test_graph_query.py | |
| +++ tests/test_graph_query.py | |
| @@ -4,11 +4,11 @@ | |
| 4 | from unittest.mock import MagicMock |
| 5 | |
| 6 | import pytest |
| 7 | |
| 8 | from video_processor.integrators.graph_query import GraphQueryEngine, QueryResult |
| 9 | from video_processor.integrators.graph_store import InMemoryStore |
| 10 | |
| 11 | |
| 12 | def _make_populated_store(): |
| 13 | """Create a store with test data.""" |
| 14 | store = InMemoryStore() |
| @@ -167,15 +167,15 @@ | |
| 167 | engine = GraphQueryEngine(store) |
| 168 | result = engine.neighbors("Ghost") |
| 169 | assert result.data == [] |
| 170 | assert "not found" in result.explanation |
| 171 | |
| 172 | def test_cypher_raises_on_inmemory(self): |
| 173 | store = InMemoryStore() |
| 174 | engine = GraphQueryEngine(store) |
| 175 | with pytest.raises(NotImplementedError): |
| 176 | engine.cypher("MATCH (n) RETURN n") |
| 177 | |
| 178 | def test_entities_limit(self): |
| 179 | store = _make_populated_store() |
| 180 | engine = GraphQueryEngine(store) |
| 181 | result = engine.entities(limit=2) |
| @@ -199,39 +199,24 @@ | |
| 199 | result = engine.stats() |
| 200 | assert result.data["entity_count"] == 2 |
| 201 | assert result.data["relationship_count"] == 1 |
| 202 | |
| 203 | |
| 204 | # Conditional FalkorDB tests |
| 205 | _falkordb_available = False |
| 206 | try: |
| 207 | import redislite # noqa: F401 |
| 208 | |
| 209 | _falkordb_available = True |
| 210 | except ImportError: |
| 211 | pass |
| 212 | |
| 213 | |
| 214 | @pytest.mark.skipif(not _falkordb_available, reason="falkordblite not installed") |
| 215 | class TestFalkorDBQuery: |
| 216 | def test_cypher_passthrough(self, tmp_path): |
| 217 | from video_processor.integrators.graph_store import FalkorDBStore |
| 218 | |
| 219 | store = FalkorDBStore(tmp_path / "test.db") |
| 220 | store.merge_entity("Python", "technology", ["A language"]) |
| 221 | engine = GraphQueryEngine(store) |
| 222 | result = engine.cypher("MATCH (e:Entity) RETURN e.name") |
| 223 | assert len(result.data) >= 1 |
| 224 | assert result.query_type == "cypher" |
| 225 | store.close() |
| 226 | |
| 227 | def test_raw_query_on_store(self, tmp_path): |
| 228 | from video_processor.integrators.graph_store import FalkorDBStore |
| 229 | |
| 230 | store = FalkorDBStore(tmp_path / "test.db") |
| 231 | store.merge_entity("Alice", "person", ["Engineer"]) |
| 232 | rows = store.raw_query("MATCH (e:Entity) RETURN e.name") |
| 233 | assert len(rows) >= 1 |
| 234 | store.close() |
| 235 | |
| 236 | |
| 237 | class TestAgenticMode: |
| 238 |
| --- tests/test_graph_query.py | |
| +++ tests/test_graph_query.py | |
| @@ -4,11 +4,11 @@ | |
| 4 | from unittest.mock import MagicMock |
| 5 | |
| 6 | import pytest |
| 7 | |
| 8 | from video_processor.integrators.graph_query import GraphQueryEngine, QueryResult |
| 9 | from video_processor.integrators.graph_store import InMemoryStore, SQLiteStore |
| 10 | |
| 11 | |
| 12 | def _make_populated_store(): |
| 13 | """Create a store with test data.""" |
| 14 | store = InMemoryStore() |
| @@ -167,15 +167,15 @@ | |
| 167 | engine = GraphQueryEngine(store) |
| 168 | result = engine.neighbors("Ghost") |
| 169 | assert result.data == [] |
| 170 | assert "not found" in result.explanation |
| 171 | |
| 172 | def test_sql_raises_on_inmemory(self): |
| 173 | store = InMemoryStore() |
| 174 | engine = GraphQueryEngine(store) |
| 175 | with pytest.raises(NotImplementedError): |
| 176 | engine.sql("SELECT * FROM entities") |
| 177 | |
| 178 | def test_entities_limit(self): |
| 179 | store = _make_populated_store() |
| 180 | engine = GraphQueryEngine(store) |
| 181 | result = engine.entities(limit=2) |
| @@ -199,39 +199,24 @@ | |
| 199 | result = engine.stats() |
| 200 | assert result.data["entity_count"] == 2 |
| 201 | assert result.data["relationship_count"] == 1 |
| 202 | |
| 203 | |
| 204 | class TestSQLiteQuery: |
| 205 | def test_sql_passthrough(self, tmp_path): |
| 206 | store = SQLiteStore(tmp_path / "test.db") |
| 207 | store.merge_entity("Python", "technology", ["A language"]) |
| 208 | engine = GraphQueryEngine(store) |
| 209 | result = engine.sql("SELECT name FROM entities") |
| 210 | assert len(result.data) >= 1 |
| 211 | assert result.query_type == "sql" |
| 212 | store.close() |
| 213 | |
| 214 | def test_raw_query_on_store(self, tmp_path): |
| 215 | store = SQLiteStore(tmp_path / "test.db") |
| 216 | store.merge_entity("Alice", "person", ["Engineer"]) |
| 217 | rows = store.raw_query("SELECT name FROM entities") |
| 218 | assert len(rows) >= 1 |
| 219 | store.close() |
| 220 | |
| 221 | |
| 222 | class TestAgenticMode: |
| 223 |
+56
-42
| --- tests/test_graph_store.py | ||
| +++ tests/test_graph_store.py | ||
| @@ -1,10 +1,8 @@ | ||
| 1 | 1 | """Tests for graph storage backends.""" |
| 2 | 2 | |
| 3 | -import pytest | |
| 4 | - | |
| 5 | -from video_processor.integrators.graph_store import InMemoryStore, create_store | |
| 3 | +from video_processor.integrators.graph_store import InMemoryStore, SQLiteStore, create_store | |
| 6 | 4 | |
| 7 | 5 | |
| 8 | 6 | class TestInMemoryStore: |
| 9 | 7 | def test_merge_entity_creates_new(self): |
| 10 | 8 | store = InMemoryStore() |
| @@ -144,58 +142,40 @@ | ||
| 144 | 142 | |
| 145 | 143 | def test_returns_in_memory_with_none_path(self): |
| 146 | 144 | store = create_store(db_path=None) |
| 147 | 145 | assert isinstance(store, InMemoryStore) |
| 148 | 146 | |
| 149 | - def test_fallback_to_in_memory_when_falkordb_unavailable(self, tmp_path): | |
| 150 | - """When falkordblite is not installed, should fall back gracefully.""" | |
| 147 | + def test_returns_sqlite_with_path(self, tmp_path): | |
| 151 | 148 | store = create_store(db_path=tmp_path / "test.db") |
| 152 | - # Will be FalkorDBStore if installed, InMemoryStore if not | |
| 153 | - # Either way, it should work | |
| 149 | + assert isinstance(store, SQLiteStore) | |
| 154 | 150 | store.merge_entity("Test", "concept", ["test entity"]) |
| 155 | 151 | assert store.get_entity_count() == 1 |
| 152 | + store.close() | |
| 156 | 153 | |
| 157 | 154 | |
| 158 | -# Conditional FalkorDB tests | |
| 159 | -_falkordb_available = False | |
| 160 | -try: | |
| 161 | - import redislite # noqa: F401 | |
| 162 | - | |
| 163 | - _falkordb_available = True | |
| 164 | -except ImportError: | |
| 165 | - pass | |
| 166 | - | |
| 167 | - | |
| 168 | -@pytest.mark.skipif(not _falkordb_available, reason="falkordblite not installed") | |
| 169 | -class TestFalkorDBStore: | |
| 155 | +class TestSQLiteStore: | |
| 170 | 156 | def test_create_and_query_entity(self, tmp_path): |
| 171 | - from video_processor.integrators.graph_store import FalkorDBStore | |
| 172 | - | |
| 173 | - store = FalkorDBStore(tmp_path / "test.db") | |
| 157 | + store = SQLiteStore(tmp_path / "test.db") | |
| 174 | 158 | store.merge_entity("Python", "technology", ["A language"]) |
| 175 | 159 | assert store.get_entity_count() == 1 |
| 176 | 160 | entity = store.get_entity("python") |
| 177 | 161 | assert entity is not None |
| 178 | 162 | assert entity["name"] == "Python" |
| 179 | 163 | store.close() |
| 180 | 164 | |
| 181 | 165 | def test_case_insensitive_merge(self, tmp_path): |
| 182 | - from video_processor.integrators.graph_store import FalkorDBStore | |
| 183 | - | |
| 184 | - store = FalkorDBStore(tmp_path / "test.db") | |
| 166 | + store = SQLiteStore(tmp_path / "test.db") | |
| 185 | 167 | store.merge_entity("Python", "technology", ["Language"]) |
| 186 | 168 | store.merge_entity("python", "technology", ["Snake-based"]) |
| 187 | 169 | assert store.get_entity_count() == 1 |
| 188 | 170 | entity = store.get_entity("python") |
| 189 | 171 | assert "Language" in entity["descriptions"] |
| 190 | 172 | assert "Snake-based" in entity["descriptions"] |
| 191 | 173 | store.close() |
| 192 | 174 | |
| 193 | 175 | def test_relationships(self, tmp_path): |
| 194 | - from video_processor.integrators.graph_store import FalkorDBStore | |
| 195 | - | |
| 196 | - store = FalkorDBStore(tmp_path / "test.db") | |
| 176 | + store = SQLiteStore(tmp_path / "test.db") | |
| 197 | 177 | store.merge_entity("Alice", "person", []) |
| 198 | 178 | store.merge_entity("Bob", "person", []) |
| 199 | 179 | store.add_relationship("Alice", "Bob", "knows") |
| 200 | 180 | assert store.get_relationship_count() == 1 |
| 201 | 181 | rels = store.get_all_relationships() |
| @@ -202,40 +182,39 @@ | ||
| 202 | 182 | assert rels[0]["source"] == "Alice" |
| 203 | 183 | assert rels[0]["target"] == "Bob" |
| 204 | 184 | store.close() |
| 205 | 185 | |
| 206 | 186 | def test_occurrences(self, tmp_path): |
| 207 | - from video_processor.integrators.graph_store import FalkorDBStore | |
| 208 | - | |
| 209 | - store = FalkorDBStore(tmp_path / "test.db") | |
| 187 | + store = SQLiteStore(tmp_path / "test.db") | |
| 210 | 188 | store.merge_entity("Alice", "person", ["Engineer"]) |
| 211 | 189 | store.add_occurrence("Alice", "transcript_0", timestamp=10.5, text="Alice said...") |
| 212 | 190 | entity = store.get_entity("alice") |
| 213 | 191 | assert len(entity["occurrences"]) == 1 |
| 214 | 192 | assert entity["occurrences"][0]["source"] == "transcript_0" |
| 215 | 193 | store.close() |
| 216 | 194 | |
| 217 | - def test_persistence(self, tmp_path): | |
| 218 | - from video_processor.integrators.graph_store import FalkorDBStore | |
| 195 | + def test_occurrence_nonexistent_entity(self, tmp_path): | |
| 196 | + store = SQLiteStore(tmp_path / "test.db") | |
| 197 | + store.add_occurrence("Ghost", "transcript_0") | |
| 198 | + assert store.get_entity_count() == 0 | |
| 199 | + store.close() | |
| 219 | 200 | |
| 201 | + def test_persistence(self, tmp_path): | |
| 220 | 202 | db_path = tmp_path / "persist.db" |
| 221 | 203 | |
| 222 | - store1 = FalkorDBStore(db_path) | |
| 204 | + store1 = SQLiteStore(db_path) | |
| 223 | 205 | store1.merge_entity("Python", "technology", ["A language"]) |
| 224 | - store1.add_relationship_count = 0 # just to trigger write | |
| 225 | 206 | store1.close() |
| 226 | 207 | |
| 227 | - store2 = FalkorDBStore(db_path) | |
| 208 | + store2 = SQLiteStore(db_path) | |
| 228 | 209 | assert store2.get_entity_count() == 1 |
| 229 | 210 | entity = store2.get_entity("python") |
| 230 | 211 | assert entity["name"] == "Python" |
| 231 | 212 | store2.close() |
| 232 | 213 |