PlanOpticon

Merge pull request #91 from ConflictHQ/feat/sqlite-migration feat: PlanOpticon v0.4.0 — planning agent, companion REPL, 20+ sources, SQLite

noreply 2026-03-07 23:57 UTC trunk merge
Commit 0981a082d9bd09a808b2fa13ef2ab23b8b463b0ddc0631c05429fb5c904577f7
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 @@
77
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.
88
99
### Auto-detection
1010
1111
Look for these files (checked automatically):
12
-- `knowledge_graph.db` — FalkorDB binary graph (preferred)
12
+- `knowledge_graph.db` — SQLite graph database (preferred)
1313
- `knowledge_graph.json` — JSON export (fallback)
1414
1515
Common locations: project root, `results/`, `output/`, `knowledge-base/`.
1616
1717
### Quick commands
1818
--- 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 @@
44
[![PyPI](https://img.shields.io/pypi/v/planopticon)](https://pypi.org/project/planopticon/)
55
[![Python](https://img.shields.io/pypi/pyversions/planopticon)](https://pypi.org/project/planopticon/)
66
[![License](https://img.shields.io/github/license/ConflictHQ/PlanOpticon)](LICENSE)
77
[![Docs](https://img.shields.io/badge/docs-planopticon.dev-blue)](https://planopticon.dev)
88
9
-**AI-powered video analysis and knowledge extraction.**
9
+**AI-powered video analysis, knowledge extraction, and planning.**
1010
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.
1212
1313
## Features
1414
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
26113
27114
## Local Run
28115
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.
30117
31118
> **13.2 hours of video content analyzed, knowledge-graphed, and summarized in ~25 hours of processing time, entirely on local hardware, for free.**
32119
33120
18 meeting recordings processed on a single machine using `llava` (vision), `qwen3:30b` (chat), and `whisper-large` (transcription via Apple Silicon GPU):
34121
@@ -41,33 +128,17 @@
41128
| API calls (local) | 1,841 |
42129
| Tokens processed | 4.87M |
43130
| Total cost | **$0.00** |
44131
45132
```bash
46
-# Fully local analysis — no API keys needed, just Ollama running
133
+# Fully local analysis -- no API keys needed, just Ollama running
47134
planopticon analyze -i meeting.mp4 -o ./output \
48135
--provider ollama \
49136
--vision-model llava:latest \
50137
--chat-model qwen3:30b
51138
```
52139
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
-
69140
## Installation
70141
71142
### From PyPI
72143
73144
```bash
@@ -109,11 +180,12 @@
109180
├── captures/ # Screengrab fallbacks
110181
└── results/
111182
├── analysis.md # Markdown report
112183
├── analysis.html # HTML report
113184
├── analysis.pdf # PDF report
114
- ├── knowledge_graph.json # Entities and relationships
185
+ ├── knowledge_graph.db # SQLite knowledge graph
186
+ ├── knowledge_graph.json # JSON export
115187
├── key_points.json # Extracted key points
116188
└── action_items.json # Tasks and follow-ups
117189
```
118190
119191
## Processing Depth
@@ -128,6 +200,6 @@
128200
129201
Full documentation at [planopticon.dev](https://planopticon.dev)
130202
131203
## License
132204
133
-MIT License — Copyright (c) 2026 CONFLICT LLC
205
+MIT License -- Copyright (c) 2026 CONFLICT LLC
134206
--- README.md
+++ README.md
@@ -4,31 +4,118 @@
4 [![PyPI](https://img.shields.io/pypi/v/planopticon)](https://pypi.org/project/planopticon/)
5 [![Python](https://img.shields.io/pypi/pyversions/planopticon)](https://pypi.org/project/planopticon/)
6 [![License](https://img.shields.io/github/license/ConflictHQ/PlanOpticon)](LICENSE)
7 [![Docs](https://img.shields.io/badge/docs-planopticon.dev-blue)](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 [![PyPI](https://img.shields.io/pypi/v/planopticon)](https://pypi.org/project/planopticon/)
5 [![Python](https://img.shields.io/pypi/pyversions/planopticon)](https://pypi.org/project/planopticon/)
6 [![License](https://img.shields.io/github/license/ConflictHQ/PlanOpticon)](LICENSE)
7 [![Docs](https://img.shields.io/badge/docs-planopticon.dev-blue)](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
--- docs/architecture/overview.md
+++ docs/architecture/overview.md
@@ -2,67 +2,144 @@
22
33
## System diagram
44
55
```mermaid
66
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]
832
A --> C[Audio Extractor]
933
B --> D[Diagram Analyzer]
1034
C --> E[Transcription]
1135
D --> F[Knowledge Graph]
1236
E --> F
1337
E --> G[Key Point Extractor]
1438
E --> H[Action Item Detector]
1539
D --> I[Content Analyzer]
1640
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
+
1755
F --> J[Plan Generator]
1856
G --> J
1957
H --> J
2058
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
2570
```
2671
2772
## Module structure
2873
2974
```
3075
video_processor/
3176
├── cli/ # CLI commands (Click)
3277
│ └── 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)
3389
├── extractors/ # Media extraction
3490
│ ├── frame_extractor.py # Video → frames
3591
│ └── audio_extractor.py # Video → WAV
3692
├── analyzers/ # AI-powered analysis
3793
│ ├── diagram_analyzer.py # Frame classification + extraction
3894
│ ├── content_analyzer.py # Cross-referencing
3995
│ └── action_detector.py # Action item detection
4096
├── integrators/ # Knowledge assembly
4197
│ ├── knowledge_graph.py # Entity/relationship graph
98
+│ ├── graph_query.py # Query engine
4299
│ └── 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
43110
├── providers/ # AI provider abstraction
44111
│ ├── base.py # BaseProvider ABC
45112
│ ├── openai_provider.py
46113
│ ├── anthropic_provider.py
47114
│ ├── gemini_provider.py
48115
│ ├── 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
49121
│ ├── discovery.py # Auto-model-discovery
50122
│ └── manager.py # ProviderManager routing
51123
├── utils/
52124
│ ├── json_parsing.py # Robust LLM JSON parsing
53125
│ ├── rendering.py # Mermaid + chart rendering
54126
│ ├── export.py # HTML/PDF export
55127
│ ├── api_cache.py # Disk-based response cache
56128
│ └── prompt_templates.py # LLM prompt management
129
+├── auth.py # OAuth flow management
130
+├── exchange.py # Exchange format schema
57131
├── models.py # Pydantic data models
58132
├── output_structure.py # Directory layout + manifest I/O
59133
└── pipeline.py # Core processing pipeline
60134
```
61135
62136
## Key design decisions
63137
64138
- **Pydantic everywhere** — All structured data uses pydantic models for validation and serialization
65139
- **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
67141
- **No hardcoded models** — Model lists come from API discovery
68142
- **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
69146
--- 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
--- docs/architecture/providers.md
+++ docs/architecture/providers.md
@@ -1,19 +1,36 @@
11
# Provider System
22
33
## Overview
44
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.
66
77
## Supported providers
88
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).
1532
1633
## Ollama (offline mode)
1734
1835
[Ollama](https://ollama.com) enables fully offline operation with no API keys required. PlanOpticon connects via Ollama's OpenAI-compatible API.
1936
@@ -51,31 +68,43 @@
5168
# Automatically discovers models from all configured providers + Ollama
5269
```
5370
5471
## Routing preferences
5572
56
-Each task type has a default preference order:
73
+Each task type has a default preference order (cheapest first):
5774
5875
| Task | Preference |
5976
|------|-----------|
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 |
6279
| Transcription | Local Whisper → Whisper-1 → Gemini Flash |
6380
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.
6582
6683
## Manual override
6784
6885
```python
6986
pm = ProviderManager(
7087
vision_model="gpt-4o",
71
- chat_model="claude-sonnet-4-5-20250929",
88
+ chat_model="claude-sonnet-4-20250514",
7289
provider="openai", # Force a specific provider
7390
)
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
+)
7497
7598
# Or use Ollama for fully offline processing
7699
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")
77106
```
78107
79108
## BaseProvider interface
80109
81110
All providers implement:
82111
--- 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
--- docs/cli-reference.md
+++ docs/cli-reference.md
@@ -1,6 +1,20 @@
11
# 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
+---
216
317
## `planopticon analyze`
418
519
Analyze a single video and extract structured knowledge.
620
@@ -95,39 +109,575 @@
95109
| `-p`, `--provider` | `auto\|openai\|anthropic\|gemini\|ollama` | `auto` | API provider |
96110
| `--vision-model` | TEXT | auto | Override vision model |
97111
| `--chat-model` | TEXT | auto | Override chat model |
98112
99113
---
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
+---
100250
101251
## `planopticon auth`
102252
103
-Authenticate with a cloud storage service for batch processing.
253
+Authenticate with cloud services via OAuth or API keys.
104254
105255
```bash
106
-planopticon auth SERVICE
256
+planopticon auth SERVICE [OPTIONS]
107257
```
108258
109259
| Argument | Values | Description |
110260
|----------|--------|-------------|
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 |
112266
113267
**Examples:**
114268
115269
```bash
116
-# Authenticate with Google Drive (interactive OAuth2)
270
+# Authenticate with Google (Drive, Meet, YouTube, etc.)
117271
planopticon auth google
118272
119273
# Authenticate with Dropbox
120274
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]
121578
```
122579
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
+```
124594
125595
---
126596
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
128680
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
+```
134684
--- 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 @@
11
# Configuration
22
33
## Environment variables
4
+
5
+### AI providers
46
57
| Variable | Description |
68
|----------|-------------|
79
| `OPENAI_API_KEY` | OpenAI API key |
810
| `ANTHROPIC_API_KEY` | Anthropic API key |
911
| `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 |
1018
| `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
+|----------|-------------|
1236
| `CACHE_DIR` | Directory for API response caching |
1337
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
+
1455
## Provider routing
1556
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:
1758
1859
| Task | Default preference |
1960
|------|--------------------|
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 |
2263
| 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`.
2366
2467
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.
2568
2669
Override with `--provider`, `--vision-model`, or `--chat-model` flags.
2770
2871
--- 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 @@
6363
Batch mode produces per-video outputs plus:
6464
6565
- Merged knowledge graph across all videos
6666
- Batch summary with aggregated action items
6767
- 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
+```
68148
69149
## Discover available models
70150
71151
```bash
72152
planopticon list-models
73153
--- 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
--- docs/guide/cloud-sources.md
+++ docs/guide/cloud-sources.md
@@ -1,8 +1,8 @@
11
# Cloud Sources
22
3
-PlanOpticon can fetch videos directly from cloud storage services.
3
+PlanOpticon connects to 20+ source platforms for fetching videos, documents, and notes.
44
55
## Google Drive
66
77
### Service account auth
88
@@ -25,10 +25,75 @@
2525
### Install
2626
2727
```bash
2828
pip install planopticon[gdrive]
2929
```
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
+```
3095
3196
## Dropbox
3297
3398
### OAuth2 auth
3499
@@ -40,11 +105,119 @@
40105
### Install
41106
42107
```bash
43108
pip install planopticon[dropbox]
44109
```
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
+```
45218
46219
## All cloud sources
47220
48221
```bash
49222
pip install planopticon[cloud]
50223
```
51224
--- 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 @@
11
# PlanOpticon
22
3
-**AI-powered video analysis and knowledge extraction.**
3
+**AI-powered video analysis, knowledge extraction, and planning.**
44
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.
66
77
---
88
99
## Features
1010
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
1217
- **Smart frame extraction** — Change detection for transitions + periodic capture (every 30s) for slow-evolving content like document scrolling
1318
- **People frame filtering** — OpenCV face detection removes webcam/video conference frames, keeping only shared content (slides, documents, screen shares)
1419
- **Diagram extraction** — Vision model-based classification detects flowcharts, architecture diagrams, charts, and whiteboards
1520
- **Knowledge graphs** — Extracts entities and relationships, builds and merges knowledge graphs across videos
1621
- **Action item detection** — Finds commitments, tasks, and follow-ups with assignees and deadlines
1722
- **Batch processing** — Process entire folders of videos with merged knowledge graphs and cross-referencing
1823
- **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
2025
- **Checkpoint/resume** — Pipeline resumes from where it left off if interrupted — no wasted work
2126
- **Screengrab fallback** — When extraction isn't perfect, captures frames with captions — something is always better than nothing
2227
2328
## Quick Start
2429
@@ -27,10 +32,25 @@
2732
pip install planopticon
2833
2934
# Analyze a single video
3035
planopticon analyze -i meeting.mp4 -o ./output
3136
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
+
3252
# Process a folder of videos
3353
planopticon batch -i ./recordings -o ./output --title "Weekly Meetings"
3454
3555
# See available AI models
3656
planopticon list-models
3757
3858
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
--- 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">&times;</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">&times;</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 @@
22
requires = ["setuptools>=69.0", "wheel"]
33
build-backend = "setuptools.build_meta"
44
55
[project]
66
name = "planopticon"
7
-version = "0.3.0"
7
+version = "0.4.0"
88
description = "AI-powered video analysis and knowledge extraction tool"
99
readme = "README.md"
1010
license = "MIT"
1111
requires-python = ">=3.10"
1212
authors = [
@@ -50,18 +50,41 @@
5050
"requests>=2.31.0",
5151
"tenacity>=8.2.0",
5252
]
5353
5454
[project.optional-dependencies]
55
-pdf = ["weasyprint>=60.0"]
55
+pdf = ["pymupdf>=1.24.0"]
5656
gpu = ["torch>=2.0.0", "torchvision>=0.15.0"]
5757
gdrive = ["google-auth>=2.0.0", "google-auth-oauthlib>=1.0.0", "google-api-python-client>=2.0.0"]
5858
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 = []
6077
cloud = [
6178
"planopticon[gdrive]",
6279
"planopticon[dropbox]",
80
+ "planopticon[s3]",
81
+]
82
+sources = [
83
+ "planopticon[youtube]",
84
+ "planopticon[web]",
85
+ "planopticon[rss]",
6386
]
6487
dev = [
6588
"pytest>=7.3.0",
6689
"pytest-cov>=4.1.0",
6790
"black>=23.3.0",
@@ -103,10 +126,11 @@
103126
[tool.ruff.lint]
104127
select = ["E", "F", "W", "I"]
105128
106129
[tool.ruff.lint.per-file-ignores]
107130
"video_processor/utils/prompt_templates.py" = ["E501"]
131
+"video_processor/api/openapi_spec.py" = ["E501"]
108132
109133
[tool.mypy]
110134
python_version = "3.10"
111135
warn_return_any = true
112136
warn_unused_configs = true
113137
--- 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."""
22
33
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
+
5378
6
-from video_processor.agent.orchestrator import AgentOrchestrator
379
+# ---------------------------------------------------------------------------
380
+# Orchestrator tests (from existing test_agent.py — kept for coverage)
381
+# ---------------------------------------------------------------------------
7382
8383
9384
class TestPlanCreation:
10385
def test_basic_plan(self):
386
+ from video_processor.agent.orchestrator import AgentOrchestrator
387
+
11388
agent = AgentOrchestrator()
12389
plan = agent._create_plan("test.mp4", "basic")
13390
steps = [s["step"] for s in plan]
14391
assert "extract_frames" in steps
15392
assert "extract_audio" in steps
@@ -18,18 +395,22 @@
18395
assert "extract_action_items" in steps
19396
assert "generate_reports" in steps
20397
assert "detect_diagrams" not in steps
21398
22399
def test_standard_plan(self):
400
+ from video_processor.agent.orchestrator import AgentOrchestrator
401
+
23402
agent = AgentOrchestrator()
24403
plan = agent._create_plan("test.mp4", "standard")
25404
steps = [s["step"] for s in plan]
26405
assert "detect_diagrams" in steps
27406
assert "build_knowledge_graph" in steps
28407
assert "deep_analysis" not in steps
29408
30409
def test_comprehensive_plan(self):
410
+ from video_processor.agent.orchestrator import AgentOrchestrator
411
+
31412
agent = AgentOrchestrator()
32413
plan = agent._create_plan("test.mp4", "comprehensive")
33414
steps = [s["step"] for s in plan]
34415
assert "detect_diagrams" in steps
35416
assert "deep_analysis" in steps
@@ -36,42 +417,52 @@
36417
assert "cross_reference" in steps
37418
38419
39420
class TestAdaptPlan:
40421
def test_adapts_for_long_transcript(self):
422
+ from video_processor.agent.orchestrator import AgentOrchestrator
423
+
41424
agent = AgentOrchestrator()
42425
agent._plan = [{"step": "generate_reports", "priority": "required"}]
43
- long_text = "word " * 3000 # > 10000 chars
426
+ long_text = "word " * 3000
44427
agent._adapt_plan("transcribe", {"text": long_text})
45428
steps = [s["step"] for s in agent._plan]
46429
assert "deep_analysis" in steps
47430
48431
def test_no_adapt_for_short_transcript(self):
432
+ from video_processor.agent.orchestrator import AgentOrchestrator
433
+
49434
agent = AgentOrchestrator()
50435
agent._plan = [{"step": "generate_reports", "priority": "required"}]
51436
agent._adapt_plan("transcribe", {"text": "Short text"})
52437
steps = [s["step"] for s in agent._plan]
53438
assert "deep_analysis" not in steps
54439
55440
def test_adapts_for_many_diagrams(self):
441
+ from video_processor.agent.orchestrator import AgentOrchestrator
442
+
56443
agent = AgentOrchestrator()
57444
agent._plan = [{"step": "generate_reports", "priority": "required"}]
58445
diagrams = [MagicMock() for _ in range(5)]
59446
agent._adapt_plan("detect_diagrams", {"diagrams": diagrams, "captures": []})
60447
steps = [s["step"] for s in agent._plan]
61448
assert "cross_reference" in steps
62449
63450
def test_insight_for_many_captures(self):
451
+ from video_processor.agent.orchestrator import AgentOrchestrator
452
+
64453
agent = AgentOrchestrator()
65454
agent._plan = []
66455
captures = [MagicMock() for _ in range(5)]
67456
diagrams = [MagicMock() for _ in range(2)]
68457
agent._adapt_plan("detect_diagrams", {"diagrams": diagrams, "captures": captures})
69458
assert len(agent._insights) == 1
70459
assert "uncertain frames" in agent._insights[0]
71460
72461
def test_no_duplicate_steps(self):
462
+ from video_processor.agent.orchestrator import AgentOrchestrator
463
+
73464
agent = AgentOrchestrator()
74465
agent._plan = [{"step": "deep_analysis", "priority": "comprehensive"}]
75466
long_text = "word " * 3000
76467
agent._adapt_plan("transcribe", {"text": long_text})
77468
deep_steps = [s for s in agent._plan if s["step"] == "deep_analysis"]
@@ -78,28 +469,35 @@
78469
assert len(deep_steps) == 1
79470
80471
81472
class TestFallbacks:
82473
def test_diagram_fallback(self):
474
+ from video_processor.agent.orchestrator import AgentOrchestrator
475
+
83476
agent = AgentOrchestrator()
84477
assert agent._get_fallback("detect_diagrams") == "screengrab_fallback"
85478
86479
def test_no_fallback_for_unknown(self):
480
+ from video_processor.agent.orchestrator import AgentOrchestrator
481
+
87482
agent = AgentOrchestrator()
88483
assert agent._get_fallback("transcribe") is None
89484
90485
91486
class TestInsights:
92487
def test_insights_property(self):
488
+ from video_processor.agent.orchestrator import AgentOrchestrator
489
+
93490
agent = AgentOrchestrator()
94491
agent._insights = ["Insight 1", "Insight 2"]
95492
assert agent.insights == ["Insight 1", "Insight 2"]
96
- # Should return a copy
97493
agent.insights.append("should not modify internal")
98494
assert len(agent._insights) == 2
99495
100496
def test_deep_analysis_populates_insights(self):
497
+ from video_processor.agent.orchestrator import AgentOrchestrator
498
+
101499
pm = MagicMock()
102500
pm.chat.return_value = json.dumps(
103501
{
104502
"decisions": ["Decided to use microservices"],
105503
"risks": ["Timeline is tight"],
@@ -113,57 +511,61 @@
113511
assert "decisions" in result
114512
assert any("microservices" in i for i in agent._insights)
115513
assert any("Timeline" in i for i in agent._insights)
116514
117515
def test_deep_analysis_handles_error(self):
516
+ from video_processor.agent.orchestrator import AgentOrchestrator
517
+
118518
pm = MagicMock()
119519
pm.chat.side_effect = Exception("API error")
120520
agent = AgentOrchestrator(provider_manager=pm)
121521
agent._results["transcribe"] = {"text": "some text"}
122522
result = agent._deep_analysis("/tmp")
123523
assert result == {}
124524
125525
def test_deep_analysis_no_transcript(self):
526
+ from video_processor.agent.orchestrator import AgentOrchestrator
527
+
126528
agent = AgentOrchestrator()
127529
agent._results["transcribe"] = {"text": ""}
128530
result = agent._deep_analysis("/tmp")
129531
assert result == {}
130532
131533
132534
class TestBuildManifest:
133535
def test_builds_from_results(self):
536
+ from video_processor.agent.orchestrator import AgentOrchestrator
537
+
134538
agent = AgentOrchestrator()
135539
agent._results = {
136540
"extract_frames": {"frames": [1, 2, 3], "paths": ["/a.jpg", "/b.jpg"]},
137541
"extract_audio": {"audio_path": "/audio.wav", "properties": {"duration": 60.0}},
138542
"detect_diagrams": {"diagrams": [], "captures": []},
139543
"extract_key_points": {"key_points": []},
140544
"extract_action_items": {"action_items": []},
141545
}
142
- from pathlib import Path
143
-
144546
manifest = agent._build_manifest(Path("test.mp4"), Path("/out"), "Test", 5.0)
145547
assert manifest.video.title == "Test"
146548
assert manifest.stats.frames_extracted == 3
147549
assert manifest.stats.duration_seconds == 5.0
148550
assert manifest.video.duration_seconds == 60.0
149551
150552
def test_handles_missing_results(self):
553
+ from video_processor.agent.orchestrator import AgentOrchestrator
554
+
151555
agent = AgentOrchestrator()
152556
agent._results = {}
153
- from pathlib import Path
154
-
155557
manifest = agent._build_manifest(Path("test.mp4"), Path("/out"), None, 1.0)
156558
assert manifest.video.title == "Analysis of test"
157559
assert manifest.stats.frames_extracted == 0
158560
159561
def test_handles_error_results(self):
562
+ from video_processor.agent.orchestrator import AgentOrchestrator
563
+
160564
agent = AgentOrchestrator()
161565
agent._results = {
162566
"extract_frames": {"error": "failed"},
163567
"detect_diagrams": {"error": "also failed"},
164568
}
165
- from pathlib import Path
166
-
167569
manifest = agent._build_manifest(Path("vid.mp4"), Path("/out"), None, 2.0)
168570
assert manifest.stats.frames_extracted == 0
169571
assert len(manifest.diagrams) == 0
170572
171573
ADDED tests/test_agent_skills.py
172574
ADDED tests/test_api_spec.py
173575
ADDED tests/test_auth.py
174576
ADDED tests/test_callbacks.py
175577
ADDED tests/test_cli.py
176578
ADDED tests/test_companion.py
177579
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
--- 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
--- 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"]
--- 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
--- 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
--- 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
--- 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
--- 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
--- tests/test_graph_query.py
+++ tests/test_graph_query.py
@@ -4,11 +4,11 @@
44
from unittest.mock import MagicMock
55
66
import pytest
77
88
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
1010
1111
1212
def _make_populated_store():
1313
"""Create a store with test data."""
1414
store = InMemoryStore()
@@ -167,15 +167,15 @@
167167
engine = GraphQueryEngine(store)
168168
result = engine.neighbors("Ghost")
169169
assert result.data == []
170170
assert "not found" in result.explanation
171171
172
- def test_cypher_raises_on_inmemory(self):
172
+ def test_sql_raises_on_inmemory(self):
173173
store = InMemoryStore()
174174
engine = GraphQueryEngine(store)
175175
with pytest.raises(NotImplementedError):
176
- engine.cypher("MATCH (n) RETURN n")
176
+ engine.sql("SELECT * FROM entities")
177177
178178
def test_entities_limit(self):
179179
store = _make_populated_store()
180180
engine = GraphQueryEngine(store)
181181
result = engine.entities(limit=2)
@@ -199,39 +199,24 @@
199199
result = engine.stats()
200200
assert result.data["entity_count"] == 2
201201
assert result.data["relationship_count"] == 1
202202
203203
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")
220207
store.merge_entity("Python", "technology", ["A language"])
221208
engine = GraphQueryEngine(store)
222
- result = engine.cypher("MATCH (e:Entity) RETURN e.name")
209
+ result = engine.sql("SELECT name FROM entities")
223210
assert len(result.data) >= 1
224
- assert result.query_type == "cypher"
211
+ assert result.query_type == "sql"
225212
store.close()
226213
227214
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")
231216
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")
233218
assert len(rows) >= 1
234219
store.close()
235220
236221
237222
class TestAgenticMode:
238223
--- 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
--- tests/test_graph_store.py
+++ tests/test_graph_store.py
@@ -1,10 +1,8 @@
11
"""Tests for graph storage backends."""
22
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
64
75
86
class TestInMemoryStore:
97
def test_merge_entity_creates_new(self):
108
store = InMemoryStore()
@@ -144,58 +142,40 @@
144142
145143
def test_returns_in_memory_with_none_path(self):
146144
store = create_store(db_path=None)
147145
assert isinstance(store, InMemoryStore)
148146
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):
151148
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)
154150
store.merge_entity("Test", "concept", ["test entity"])
155151
assert store.get_entity_count() == 1
152
+ store.close()
156153
157154
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:
170156
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")
174158
store.merge_entity("Python", "technology", ["A language"])
175159
assert store.get_entity_count() == 1
176160
entity = store.get_entity("python")
177161
assert entity is not None
178162
assert entity["name"] == "Python"
179163
store.close()
180164
181165
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")
185167
store.merge_entity("Python", "technology", ["Language"])
186168
store.merge_entity("python", "technology", ["Snake-based"])
187169
assert store.get_entity_count() == 1
188170
entity = store.get_entity("python")
189171
assert "Language" in entity["descriptions"]
190172
assert "Snake-based" in entity["descriptions"]
191173
store.close()
192174
193175
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")
197177
store.merge_entity("Alice", "person", [])
198178
store.merge_entity("Bob", "person", [])
199179
store.add_relationship("Alice", "Bob", "knows")
200180
assert store.get_relationship_count() == 1
201181
rels = store.get_all_relationships()
@@ -202,40 +182,39 @@
202182
assert rels[0]["source"] == "Alice"
203183
assert rels[0]["target"] == "Bob"
204184
store.close()
205185
206186
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")
210188
store.merge_entity("Alice", "person", ["Engineer"])
211189
store.add_occurrence("Alice", "transcript_0", timestamp=10.5, text="Alice said...")
212190
entity = store.get_entity("alice")
213191
assert len(entity["occurrences"]) == 1
214192
assert entity["occurrences"][0]["source"] == "transcript_0"
215193
store.close()
216194
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()
219200
201
+ def test_persistence(self, tmp_path):
220202
db_path = tmp_path / "persist.db"
221203
222
- store1 = FalkorDBStore(db_path)
204
+ store1 = SQLiteStore(db_path)
223205
store1.merge_entity("Python", "technology", ["A language"])
224
- store1.add_relationship_count = 0 # just to trigger write
225206
store1.close()
226207
227
- store2 = FalkorDBStore(db_path)
208
+ store2 = SQLiteStore(db_path)
228209
assert store2.get_entity_count() == 1
229210
entity = store2.get_entity("python")
230211
assert entity["name"] == "Python"
231212
store2.close()
232213