PlanOpticon

planopticon / video_processor / cli / commands.py
Source Blame History 2329 lines
a94205b… leo 1 """Command-line interface for PlanOpticon."""
09a0b7a… leo 2
a94205b… leo 3 import json
a94205b… leo 4 import logging
a94205b… leo 5 import os
a94205b… leo 6 import sys
a94205b… leo 7 from pathlib import Path
a94205b… leo 8
a94205b… leo 9 import click
a94205b… leo 10 import colorlog
287a3bb… leo 11 from tqdm import tqdm
09a0b7a… leo 12
09a0b7a… leo 13
a94205b… leo 14 def setup_logging(verbose: bool = False) -> None:
a94205b… leo 15 """Set up logging with color formatting."""
a94205b… leo 16 log_level = logging.DEBUG if verbose else logging.INFO
a94205b… leo 17 formatter = colorlog.ColoredFormatter(
a94205b… leo 18 "%(log_color)s%(asctime)s [%(levelname)s] %(message)s",
a94205b… leo 19 datefmt="%Y-%m-%d %H:%M:%S",
a94205b… leo 20 log_colors={
09a0b7a… leo 21 "DEBUG": "cyan",
09a0b7a… leo 22 "INFO": "green",
09a0b7a… leo 23 "WARNING": "yellow",
09a0b7a… leo 24 "ERROR": "red",
09a0b7a… leo 25 "CRITICAL": "red,bg_white",
09a0b7a… leo 26 },
09a0b7a… leo 27 )
a94205b… leo 28 console_handler = logging.StreamHandler()
a94205b… leo 29 console_handler.setFormatter(formatter)
a94205b… leo 30 root_logger = logging.getLogger()
a94205b… leo 31 root_logger.setLevel(log_level)
a94205b… leo 32 for handler in root_logger.handlers:
a94205b… leo 33 root_logger.removeHandler(handler)
a94205b… leo 34 root_logger.addHandler(console_handler)
a94205b… leo 35
09a0b7a… leo 36
ecf907c… leo 37 @click.group(invoke_without_command=True)
09a0b7a… leo 38 @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
0981a08… noreply 39 @click.option(
0981a08… noreply 40 "--chat",
0981a08… noreply 41 "-C",
0981a08… noreply 42 is_flag=True,
0981a08… noreply 43 help="Launch interactive companion REPL",
0981a08… noreply 44 )
0981a08… noreply 45 @click.option(
0981a08… noreply 46 "--interactive",
0981a08… noreply 47 "-I",
0981a08… noreply 48 "interactive_flag",
0981a08… noreply 49 is_flag=True,
0981a08… noreply 50 help="Launch interactive companion REPL",
0981a08… noreply 51 )
39eab41… noreply 52 @click.version_option("0.5.0", prog_name="PlanOpticon")
09a0b7a… leo 53 @click.pass_context
0981a08… noreply 54 def cli(ctx, verbose, chat, interactive_flag):
09a0b7a… leo 55 """PlanOpticon - Comprehensive Video Analysis & Knowledge Extraction Tool."""
09a0b7a… leo 56 ctx.ensure_object(dict)
09a0b7a… leo 57 ctx.obj["verbose"] = verbose
09a0b7a… leo 58 setup_logging(verbose)
09a0b7a… leo 59
0981a08… noreply 60 if (chat or interactive_flag) and ctx.invoked_subcommand is None:
0981a08… noreply 61 from video_processor.cli.companion import CompanionREPL
0981a08… noreply 62
0981a08… noreply 63 repl = CompanionREPL()
0981a08… noreply 64 repl.run()
0981a08… noreply 65 ctx.exit(0)
0981a08… noreply 66 elif ctx.invoked_subcommand is None:
ecf907c… leo 67 _interactive_menu(ctx)
1707c67… noreply 68
1707c67… noreply 69
1707c67… noreply 70 @cli.command("init")
1707c67… noreply 71 @click.pass_context
1707c67… noreply 72 def init_cmd(ctx):
1707c67… noreply 73 """Interactive setup wizard — configure providers, API keys, and .env."""
1707c67… noreply 74 from video_processor.cli.init_wizard import run_wizard
1707c67… noreply 75
1707c67… noreply 76 run_wizard()
1707c67… noreply 77
1707c67… noreply 78
1707c67… noreply 79 @cli.command()
1707c67… noreply 80 @click.pass_context
1707c67… noreply 81 def doctor(ctx):
1707c67… noreply 82 """Check setup health — Python, FFmpeg, API keys, dependencies."""
1707c67… noreply 83 from video_processor.cli.doctor import format_results, run_all_checks
1707c67… noreply 84
1707c67… noreply 85 results = run_all_checks()
1707c67… noreply 86 click.echo(format_results(results))
829e24a… leo 87
829e24a… leo 88
09a0b7a… leo 89 @cli.command()
829e24a… leo 90 @click.option(
829e24a… leo 91 "--input", "-i", required=True, type=click.Path(exists=True), help="Input video file path"
829e24a… leo 92 )
09a0b7a… leo 93 @click.option("--output", "-o", required=True, type=click.Path(), help="Output directory")
09a0b7a… leo 94 @click.option(
09a0b7a… leo 95 "--depth",
09a0b7a… leo 96 type=click.Choice(["basic", "standard", "comprehensive"]),
09a0b7a… leo 97 default="standard",
09a0b7a… leo 98 help="Processing depth",
09a0b7a… leo 99 )
829e24a… leo 100 @click.option(
829e24a… leo 101 "--focus", type=str, help='Comma-separated focus areas (e.g., "diagrams,action-items")'
829e24a… leo 102 )
09a0b7a… leo 103 @click.option("--use-gpu", is_flag=True, help="Enable GPU acceleration if available")
09a0b7a… leo 104 @click.option("--sampling-rate", type=float, default=0.5, help="Frame sampling rate")
09a0b7a… leo 105 @click.option("--change-threshold", type=float, default=0.15, help="Visual change threshold")
829e24a… leo 106 @click.option(
829e24a… leo 107 "--periodic-capture",
829e24a… leo 108 type=float,
829e24a… leo 109 default=30.0,
829e24a… leo 110 help="Capture a frame every N seconds regardless of change (0 to disable)",
829e24a… leo 111 )
09a0b7a… leo 112 @click.option("--title", type=str, help="Title for the analysis report")
09a0b7a… leo 113 @click.option(
09a0b7a… leo 114 "--provider",
09a0b7a… leo 115 "-p",
0981a08… noreply 116 type=click.Choice(
0981a08… noreply 117 [
0981a08… noreply 118 "auto",
0981a08… noreply 119 "openai",
0981a08… noreply 120 "anthropic",
0981a08… noreply 121 "gemini",
0981a08… noreply 122 "ollama",
0981a08… noreply 123 "azure",
0981a08… noreply 124 "together",
0981a08… noreply 125 "fireworks",
0981a08… noreply 126 "cerebras",
0981a08… noreply 127 "xai",
0981a08… noreply 128 ]
0981a08… noreply 129 ),
09a0b7a… leo 130 default="auto",
09a0b7a… leo 131 help="API provider",
09a0b7a… leo 132 )
09a0b7a… leo 133 @click.option("--vision-model", type=str, default=None, help="Override model for vision tasks")
09a0b7a… leo 134 @click.option("--chat-model", type=str, default=None, help="Override model for LLM/chat tasks")
0981a08… noreply 135 @click.option(
0981a08… noreply 136 "--output-format",
0981a08… noreply 137 type=click.Choice(["default", "json"]),
0981a08… noreply 138 default="default",
0981a08… noreply 139 help="Output format: default (files + summary) or json (structured JSON to stdout)",
0981a08… noreply 140 )
0981a08… noreply 141 @click.option(
0981a08… noreply 142 "--templates-dir",
0981a08… noreply 143 type=click.Path(exists=True),
0981a08… noreply 144 default=None,
0981a08… noreply 145 help="Directory with custom prompt template .txt files",
0981a08… noreply 146 )
0981a08… noreply 147 @click.option(
0981a08… noreply 148 "--speakers",
0981a08… noreply 149 type=str,
0981a08… noreply 150 default=None,
0981a08… noreply 151 help='Comma-separated speaker names for diarization hints (e.g., "Alice,Bob,Carol")',
0981a08… noreply 152 )
09a0b7a… leo 153 @click.pass_context
09a0b7a… leo 154 def analyze(
09a0b7a… leo 155 ctx,
09a0b7a… leo 156 input,
09a0b7a… leo 157 output,
09a0b7a… leo 158 depth,
09a0b7a… leo 159 focus,
09a0b7a… leo 160 use_gpu,
09a0b7a… leo 161 sampling_rate,
09a0b7a… leo 162 change_threshold,
287a3bb… leo 163 periodic_capture,
09a0b7a… leo 164 title,
09a0b7a… leo 165 provider,
09a0b7a… leo 166 vision_model,
09a0b7a… leo 167 chat_model,
0981a08… noreply 168 output_format,
0981a08… noreply 169 templates_dir,
0981a08… noreply 170 speakers,
09a0b7a… leo 171 ):
09a0b7a… leo 172 """Analyze a single video and extract structured knowledge."""
09a0b7a… leo 173 from video_processor.pipeline import process_single_video
09a0b7a… leo 174 from video_processor.providers.manager import ProviderManager
09a0b7a… leo 175
09a0b7a… leo 176 focus_areas = [a.strip().lower() for a in focus.split(",")] if focus else []
0981a08… noreply 177 speaker_hints = [s.strip() for s in speakers.split(",")] if speakers else None
09a0b7a… leo 178 prov = None if provider == "auto" else provider
09a0b7a… leo 179
09a0b7a… leo 180 pm = ProviderManager(
09a0b7a… leo 181 vision_model=vision_model,
09a0b7a… leo 182 chat_model=chat_model,
09a0b7a… leo 183 provider=prov,
09a0b7a… leo 184 )
829e24a… leo 185
0981a08… noreply 186 if templates_dir:
0981a08… noreply 187 from video_processor.utils.prompt_templates import PromptTemplate
0981a08… noreply 188
0981a08… noreply 189 pm.prompt_templates = PromptTemplate(templates_dir=templates_dir)
0981a08… noreply 190
09a0b7a… leo 191 try:
0981a08… noreply 192 manifest = process_single_video(
09a0b7a… leo 193 input_path=input,
09a0b7a… leo 194 output_dir=output,
09a0b7a… leo 195 provider_manager=pm,
09a0b7a… leo 196 depth=depth,
09a0b7a… leo 197 focus_areas=focus_areas,
09a0b7a… leo 198 sampling_rate=sampling_rate,
09a0b7a… leo 199 change_threshold=change_threshold,
287a3bb… leo 200 periodic_capture_seconds=periodic_capture,
09a0b7a… leo 201 use_gpu=use_gpu,
09a0b7a… leo 202 title=title,
0981a08… noreply 203 speaker_hints=speaker_hints,
09a0b7a… leo 204 )
0981a08… noreply 205 if output_format == "json":
0981a08… noreply 206 click.echo(json.dumps(manifest.model_dump(), indent=2, default=str))
0981a08… noreply 207 else:
0981a08… noreply 208 click.echo(pm.usage.format_summary())
0981a08… noreply 209 click.echo(f"\n Results: {output}/manifest.json")
09a0b7a… leo 210 except Exception as e:
09a0b7a… leo 211 logging.error(f"Error: {e}")
0981a08… noreply 212 if output_format == "json":
0981a08… noreply 213 click.echo(json.dumps({"error": str(e)}))
0981a08… noreply 214 else:
0981a08… noreply 215 click.echo(pm.usage.format_summary())
09a0b7a… leo 216 if ctx.obj["verbose"]:
09a0b7a… leo 217 import traceback
09a0b7a… leo 218
09a0b7a… leo 219 traceback.print_exc()
09a0b7a… leo 220 sys.exit(1)
09a0b7a… leo 221
09a0b7a… leo 222
09a0b7a… leo 223 @cli.command()
829e24a… leo 224 @click.option(
829e24a… leo 225 "--input-dir", "-i", type=click.Path(), default=None, help="Local directory of videos"
829e24a… leo 226 )
09a0b7a… leo 227 @click.option("--output", "-o", required=True, type=click.Path(), help="Output directory")
09a0b7a… leo 228 @click.option(
09a0b7a… leo 229 "--depth",
09a0b7a… leo 230 type=click.Choice(["basic", "standard", "comprehensive"]),
09a0b7a… leo 231 default="standard",
09a0b7a… leo 232 help="Processing depth",
09a0b7a… leo 233 )
09a0b7a… leo 234 @click.option(
09a0b7a… leo 235 "--pattern",
09a0b7a… leo 236 type=str,
09a0b7a… leo 237 default="*.mp4,*.mkv,*.avi,*.mov,*.webm",
09a0b7a… leo 238 help="File glob patterns (comma-separated)",
09a0b7a… leo 239 )
09a0b7a… leo 240 @click.option("--title", type=str, default="Batch Processing Results", help="Batch title")
09a0b7a… leo 241 @click.option(
09a0b7a… leo 242 "--provider",
09a0b7a… leo 243 "-p",
0981a08… noreply 244 type=click.Choice(
0981a08… noreply 245 [
0981a08… noreply 246 "auto",
0981a08… noreply 247 "openai",
0981a08… noreply 248 "anthropic",
0981a08… noreply 249 "gemini",
0981a08… noreply 250 "ollama",
0981a08… noreply 251 "azure",
0981a08… noreply 252 "together",
0981a08… noreply 253 "fireworks",
0981a08… noreply 254 "cerebras",
0981a08… noreply 255 "xai",
0981a08… noreply 256 ]
0981a08… noreply 257 ),
09a0b7a… leo 258 default="auto",
09a0b7a… leo 259 help="API provider",
09a0b7a… leo 260 )
09a0b7a… leo 261 @click.option("--vision-model", type=str, default=None, help="Override model for vision tasks")
09a0b7a… leo 262 @click.option("--chat-model", type=str, default=None, help="Override model for LLM/chat tasks")
a6b6869… leo 263 @click.option(
a6b6869… leo 264 "--source",
a6b6869… leo 265 type=click.Choice(["local", "gdrive", "dropbox"]),
a6b6869… leo 266 default="local",
a6b6869… leo 267 help="Video source (local directory, Google Drive, or Dropbox)",
a6b6869… leo 268 )
a6b6869… leo 269 @click.option("--folder-id", type=str, default=None, help="Google Drive folder ID")
a6b6869… leo 270 @click.option("--folder-path", type=str, default=None, help="Cloud folder path")
829e24a… leo 271 @click.option(
829e24a… leo 272 "--recursive/--no-recursive", default=True, help="Recurse into subfolders (default: recursive)"
829e24a… leo 273 )
09a0b7a… leo 274 @click.pass_context
829e24a… leo 275 def batch(
829e24a… leo 276 ctx,
829e24a… leo 277 input_dir,
829e24a… leo 278 output,
829e24a… leo 279 depth,
829e24a… leo 280 pattern,
829e24a… leo 281 title,
829e24a… leo 282 provider,
829e24a… leo 283 vision_model,
829e24a… leo 284 chat_model,
829e24a… leo 285 source,
829e24a… leo 286 folder_id,
829e24a… leo 287 folder_path,
829e24a… leo 288 recursive,
829e24a… leo 289 ):
09a0b7a… leo 290 """Process a folder of videos in batch."""
09a0b7a… leo 291 from video_processor.integrators.knowledge_graph import KnowledgeGraph
09a0b7a… leo 292 from video_processor.integrators.plan_generator import PlanGenerator
09a0b7a… leo 293 from video_processor.models import BatchManifest, BatchVideoEntry
09a0b7a… leo 294 from video_processor.output_structure import (
09a0b7a… leo 295 create_batch_output_dirs,
09a0b7a… leo 296 write_batch_manifest,
09a0b7a… leo 297 )
09a0b7a… leo 298 from video_processor.pipeline import process_single_video
09a0b7a… leo 299 from video_processor.providers.manager import ProviderManager
09a0b7a… leo 300
09a0b7a… leo 301 prov = None if provider == "auto" else provider
09a0b7a… leo 302 pm = ProviderManager(vision_model=vision_model, chat_model=chat_model, provider=prov)
09a0b7a… leo 303 patterns = [p.strip() for p in pattern.split(",")]
a6b6869… leo 304
a6b6869… leo 305 # Handle cloud sources
a6b6869… leo 306 if source != "local":
a6b6869… leo 307 download_dir = Path(output) / "_downloads"
a6b6869… leo 308 download_dir.mkdir(parents=True, exist_ok=True)
a6b6869… leo 309
a6b6869… leo 310 if source == "gdrive":
a6b6869… leo 311 from video_processor.sources.google_drive import GoogleDriveSource
a6b6869… leo 312
a6b6869… leo 313 cloud = GoogleDriveSource()
a6b6869… leo 314 if not cloud.authenticate():
a6b6869… leo 315 logging.error("Google Drive authentication failed")
a6b6869… leo 316 sys.exit(1)
829e24a… leo 317 cloud_files = cloud.list_videos(
829e24a… leo 318 folder_id=folder_id, folder_path=folder_path, patterns=patterns, recursive=recursive
829e24a… leo 319 )
829e24a… leo 320 cloud.download_all(cloud_files, download_dir)
a6b6869… leo 321 elif source == "dropbox":
a6b6869… leo 322 from video_processor.sources.dropbox_source import DropboxSource
a6b6869… leo 323
a6b6869… leo 324 cloud = DropboxSource()
a6b6869… leo 325 if not cloud.authenticate():
a6b6869… leo 326 logging.error("Dropbox authentication failed")
a6b6869… leo 327 sys.exit(1)
a6b6869… leo 328 cloud_files = cloud.list_videos(folder_path=folder_path, patterns=patterns)
829e24a… leo 329 cloud.download_all(cloud_files, download_dir)
a6b6869… leo 330 else:
a6b6869… leo 331 logging.error(f"Unknown source: {source}")
a6b6869… leo 332 sys.exit(1)
a6b6869… leo 333
a6b6869… leo 334 input_dir = download_dir
a6b6869… leo 335 else:
a6b6869… leo 336 if not input_dir:
a6b6869… leo 337 logging.error("--input-dir is required for local source")
a6b6869… leo 338 sys.exit(1)
a6b6869… leo 339 input_dir = Path(input_dir)
a6b6869… leo 340
287a3bb… leo 341 # Find videos (rglob for recursive, glob for flat)
09a0b7a… leo 342 videos = []
287a3bb… leo 343 glob_fn = input_dir.rglob if recursive else input_dir.glob
09a0b7a… leo 344 for pat in patterns:
287a3bb… leo 345 videos.extend(sorted(glob_fn(pat)))
09a0b7a… leo 346 videos = sorted(set(videos))
09a0b7a… leo 347
09a0b7a… leo 348 if not videos:
09a0b7a… leo 349 logging.error(f"No videos found in {input_dir} matching {pattern}")
09a0b7a… leo 350 sys.exit(1)
09a0b7a… leo 351
09a0b7a… leo 352 logging.info(f"Found {len(videos)} videos to process")
09a0b7a… leo 353
09a0b7a… leo 354 dirs = create_batch_output_dirs(output, title)
09a0b7a… leo 355 manifests = []
09a0b7a… leo 356 entries = []
0ad36b7… noreply 357 merged_kg_db = Path(output) / "knowledge_graph.db"
0ad36b7… noreply 358 merged_kg = KnowledgeGraph(db_path=merged_kg_db)
09a0b7a… leo 359
287a3bb… leo 360 for idx, video_path in enumerate(tqdm(videos, desc="Batch processing", unit="video")):
09a0b7a… leo 361 video_name = video_path.stem
09a0b7a… leo 362 video_output = dirs["videos"] / video_name
09a0b7a… leo 363 logging.info(f"Processing video {idx + 1}/{len(videos)}: {video_path.name}")
09a0b7a… leo 364
09a0b7a… leo 365 entry = BatchVideoEntry(
09a0b7a… leo 366 video_name=video_name,
09a0b7a… leo 367 manifest_path=f"videos/{video_name}/manifest.json",
09a0b7a… leo 368 )
09a0b7a… leo 369
09a0b7a… leo 370 try:
09a0b7a… leo 371 manifest = process_single_video(
09a0b7a… leo 372 input_path=video_path,
09a0b7a… leo 373 output_dir=video_output,
09a0b7a… leo 374 provider_manager=pm,
09a0b7a… leo 375 depth=depth,
09a0b7a… leo 376 title=f"Analysis of {video_name}",
09a0b7a… leo 377 )
09a0b7a… leo 378 entry.status = "completed"
09a0b7a… leo 379 entry.diagrams_count = len(manifest.diagrams)
09a0b7a… leo 380 entry.action_items_count = len(manifest.action_items)
09a0b7a… leo 381 entry.key_points_count = len(manifest.key_points)
09a0b7a… leo 382 entry.duration_seconds = manifest.video.duration_seconds
09a0b7a… leo 383 manifests.append(manifest)
09a0b7a… leo 384
0981a08… noreply 385 # Merge knowledge graph (prefer .db, fall back to .json)
0981a08… noreply 386 kg_db = video_output / "results" / "knowledge_graph.db"
0981a08… noreply 387 kg_json = video_output / "results" / "knowledge_graph.json"
0981a08… noreply 388 if kg_db.exists():
0981a08… noreply 389 video_kg = KnowledgeGraph(db_path=kg_db)
0981a08… noreply 390 merged_kg.merge(video_kg)
0981a08… noreply 391 elif kg_json.exists():
0981a08… noreply 392 kg_data = json.loads(kg_json.read_text())
09a0b7a… leo 393 video_kg = KnowledgeGraph.from_dict(kg_data)
09a0b7a… leo 394 merged_kg.merge(video_kg)
09a0b7a… leo 395
09a0b7a… leo 396 except Exception as e:
09a0b7a… leo 397 logging.error(f"Failed to process {video_path.name}: {e}")
09a0b7a… leo 398 entry.status = "failed"
09a0b7a… leo 399 entry.error = str(e)
09a0b7a… leo 400 if ctx.obj["verbose"]:
09a0b7a… leo 401 import traceback
09a0b7a… leo 402
09a0b7a… leo 403 traceback.print_exc()
09a0b7a… leo 404
09a0b7a… leo 405 entries.append(entry)
09a0b7a… leo 406
0981a08… noreply 407 # Save merged knowledge graph (SQLite is primary, JSON is export)
0981a08… noreply 408 merged_kg.save(Path(output) / "knowledge_graph.json")
09a0b7a… leo 409
09a0b7a… leo 410 # Generate batch summary
09a0b7a… leo 411 plan_gen = PlanGenerator(provider_manager=pm, knowledge_graph=merged_kg)
09a0b7a… leo 412 summary_path = Path(output) / "batch_summary.md"
09a0b7a… leo 413 plan_gen.generate_batch_summary(
09a0b7a… leo 414 manifests=manifests,
09a0b7a… leo 415 kg=merged_kg,
09a0b7a… leo 416 title=title,
09a0b7a… leo 417 output_path=summary_path,
09a0b7a… leo 418 )
09a0b7a… leo 419
09a0b7a… leo 420 # Write batch manifest
09a0b7a… leo 421 batch_manifest = BatchManifest(
09a0b7a… leo 422 title=title,
09a0b7a… leo 423 total_videos=len(videos),
09a0b7a… leo 424 completed_videos=sum(1 for e in entries if e.status == "completed"),
09a0b7a… leo 425 failed_videos=sum(1 for e in entries if e.status == "failed"),
09a0b7a… leo 426 total_diagrams=sum(e.diagrams_count for e in entries),
09a0b7a… leo 427 total_action_items=sum(e.action_items_count for e in entries),
09a0b7a… leo 428 total_key_points=sum(e.key_points_count for e in entries),
09a0b7a… leo 429 videos=entries,
09a0b7a… leo 430 batch_summary_md="batch_summary.md",
09a0b7a… leo 431 merged_knowledge_graph_json="knowledge_graph.json",
0ad36b7… noreply 432 merged_knowledge_graph_db="knowledge_graph.db",
09a0b7a… leo 433 )
09a0b7a… leo 434 write_batch_manifest(batch_manifest, output)
287a3bb… leo 435 click.echo(pm.usage.format_summary())
829e24a… leo 436 click.echo(
829e24a… leo 437 f"\n Batch complete: {batch_manifest.completed_videos}"
829e24a… leo 438 f"/{batch_manifest.total_videos} succeeded"
829e24a… leo 439 )
287a3bb… leo 440 click.echo(f" Results: {output}/batch_manifest.json")
0981a08… noreply 441
0981a08… noreply 442
0981a08… noreply 443 @cli.command()
0981a08… noreply 444 @click.argument("input_path", type=click.Path(exists=True))
0981a08… noreply 445 @click.option(
0981a08… noreply 446 "--output", "-o", type=click.Path(), default=None, help="Output directory for knowledge graph"
0981a08… noreply 447 )
0981a08… noreply 448 @click.option(
0981a08… noreply 449 "--db-path", type=click.Path(), default=None, help="Existing knowledge_graph.db to add to"
0981a08… noreply 450 )
0981a08… noreply 451 @click.option("--recursive/--no-recursive", "-r", default=True, help="Recurse into subdirectories")
0981a08… noreply 452 @click.option(
0981a08… noreply 453 "--provider",
0981a08… noreply 454 "-p",
0981a08… noreply 455 type=click.Choice(
0981a08… noreply 456 [
0981a08… noreply 457 "auto",
0981a08… noreply 458 "openai",
0981a08… noreply 459 "anthropic",
0981a08… noreply 460 "gemini",
0981a08… noreply 461 "ollama",
0981a08… noreply 462 "azure",
0981a08… noreply 463 "together",
0981a08… noreply 464 "fireworks",
0981a08… noreply 465 "cerebras",
0981a08… noreply 466 "xai",
0981a08… noreply 467 ]
0981a08… noreply 468 ),
0981a08… noreply 469 default="auto",
0981a08… noreply 470 help="API provider",
0981a08… noreply 471 )
0981a08… noreply 472 @click.option("--chat-model", type=str, default=None, help="Override model for LLM/chat tasks")
0981a08… noreply 473 @click.pass_context
0981a08… noreply 474 def ingest(ctx, input_path, output, db_path, recursive, provider, chat_model):
0981a08… noreply 475 """Ingest documents into a knowledge graph.
0981a08… noreply 476
0981a08… noreply 477 Supports: .md, .txt, .pdf (with pymupdf or pdfplumber installed)
0981a08… noreply 478
0981a08… noreply 479 Examples:
0981a08… noreply 480
0981a08… noreply 481 planopticon ingest spec.md
0981a08… noreply 482
0981a08… noreply 483 planopticon ingest ./docs/ -o ./output
0981a08… noreply 484
0981a08… noreply 485 planopticon ingest report.pdf --db-path existing.db
0981a08… noreply 486 """
0981a08… noreply 487 from video_processor.integrators.knowledge_graph import KnowledgeGraph
0981a08… noreply 488 from video_processor.processors import list_supported_extensions
0981a08… noreply 489 from video_processor.processors.ingest import ingest_directory, ingest_file
0981a08… noreply 490 from video_processor.providers.manager import ProviderManager
0981a08… noreply 491
0981a08… noreply 492 input_path = Path(input_path)
0981a08… noreply 493 prov = None if provider == "auto" else provider
0981a08… noreply 494 pm = ProviderManager(chat_model=chat_model, provider=prov)
0981a08… noreply 495
0981a08… noreply 496 # Determine DB path
0981a08… noreply 497 if db_path:
0981a08… noreply 498 kg_path = Path(db_path)
0981a08… noreply 499 elif output:
0981a08… noreply 500 out_dir = Path(output)
0981a08… noreply 501 out_dir.mkdir(parents=True, exist_ok=True)
0981a08… noreply 502 kg_path = out_dir / "knowledge_graph.db"
0981a08… noreply 503 else:
0981a08… noreply 504 kg_path = Path.cwd() / "knowledge_graph.db"
0981a08… noreply 505
0981a08… noreply 506 kg_path.parent.mkdir(parents=True, exist_ok=True)
0981a08… noreply 507
0981a08… noreply 508 click.echo(f"Knowledge graph: {kg_path}")
0981a08… noreply 509 kg = KnowledgeGraph(provider_manager=pm, db_path=kg_path)
0981a08… noreply 510
0981a08… noreply 511 total_files = 0
0981a08… noreply 512 total_chunks = 0
0981a08… noreply 513
0981a08… noreply 514 try:
0981a08… noreply 515 if input_path.is_file():
0981a08… noreply 516 count = ingest_file(input_path, kg)
0981a08… noreply 517 total_files = 1
0981a08… noreply 518 total_chunks = count
0981a08… noreply 519 click.echo(f" {input_path.name}: {count} chunks")
0981a08… noreply 520 elif input_path.is_dir():
0981a08… noreply 521 results = ingest_directory(input_path, kg, recursive=recursive)
0981a08… noreply 522 total_files = len(results)
0981a08… noreply 523 total_chunks = sum(results.values())
0981a08… noreply 524 for fpath, count in results.items():
0981a08… noreply 525 click.echo(f" {Path(fpath).name}: {count} chunks")
0981a08… noreply 526 else:
0981a08… noreply 527 click.echo(f"Error: {input_path} is not a file or directory", err=True)
0981a08… noreply 528 sys.exit(1)
0981a08… noreply 529 except ValueError as e:
0981a08… noreply 530 click.echo(f"Error: {e}", err=True)
0981a08… noreply 531 click.echo(f"Supported extensions: {', '.join(list_supported_extensions())}")
0981a08… noreply 532 sys.exit(1)
0981a08… noreply 533 except ImportError as e:
0981a08… noreply 534 click.echo(f"Error: {e}", err=True)
0981a08… noreply 535 sys.exit(1)
0981a08… noreply 536
0981a08… noreply 537 # Save both .db and .json
0981a08… noreply 538 kg.save(kg_path)
0981a08… noreply 539 json_path = kg_path.with_suffix(".json")
0981a08… noreply 540 kg.save(json_path)
0981a08… noreply 541
0981a08… noreply 542 entity_count = kg._store.get_entity_count()
0981a08… noreply 543 rel_count = kg._store.get_relationship_count()
0981a08… noreply 544
0981a08… noreply 545 click.echo("\nIngestion complete:")
0981a08… noreply 546 click.echo(f" Files processed: {total_files}")
0981a08… noreply 547 click.echo(f" Total chunks: {total_chunks}")
0981a08… noreply 548 click.echo(f" Entities extracted: {entity_count}")
0981a08… noreply 549 click.echo(f" Relationships: {rel_count}")
0981a08… noreply 550 click.echo(f" Knowledge graph: {kg_path}")
a0146a5… noreply 551
a0146a5… noreply 552
09a0b7a… leo 553 @cli.command("list-models")
a94205b… leo 554 @click.pass_context
a94205b… leo 555 def list_models(ctx):
a94205b… leo 556 """Discover and display available models from all configured providers."""
a94205b… leo 557 from video_processor.providers.discovery import discover_available_models
a94205b… leo 558
a94205b… leo 559 models = discover_available_models(force_refresh=True)
a94205b… leo 560 if not models:
a0146a5… noreply 561 click.echo(
a0146a5… noreply 562 "No models discovered. Check that at least one API key is set or Ollama is running:"
a0146a5… noreply 563 )
a0146a5… noreply 564 click.echo(" OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, or `ollama serve`")
a94205b… leo 565 return
a94205b… leo 566
a94205b… leo 567 by_provider: dict[str, list] = {}
a94205b… leo 568 for m in models:
a94205b… leo 569 by_provider.setdefault(m.provider, []).append(m)
a94205b… leo 570
a94205b… leo 571 for provider, provider_models in sorted(by_provider.items()):
a94205b… leo 572 click.echo(f"\n{provider.upper()} ({len(provider_models)} models)")
a94205b… leo 573 click.echo("-" * 60)
a94205b… leo 574 for m in provider_models:
a94205b… leo 575 caps = ", ".join(m.capabilities)
a94205b… leo 576 click.echo(f" {m.id:<40} [{caps}]")
a94205b… leo 577
a94205b… leo 578 click.echo(f"\nTotal: {len(models)} models across {len(by_provider)} providers")
a94205b… leo 579
a94205b… leo 580
09a0b7a… leo 581 @cli.command()
09a0b7a… leo 582 @click.option("--cache-dir", type=click.Path(), help="Path to cache directory")
09a0b7a… leo 583 @click.option("--older-than", type=int, help="Clear entries older than N seconds")
09a0b7a… leo 584 @click.option("--all", "clear_all", is_flag=True, help="Clear all cache entries")
09a0b7a… leo 585 @click.pass_context
09a0b7a… leo 586 def clear_cache(ctx, cache_dir, older_than, clear_all):
09a0b7a… leo 587 """Clear API response cache."""
09a0b7a… leo 588 if not cache_dir and not os.environ.get("CACHE_DIR"):
09a0b7a… leo 589 logging.error("Cache directory not specified")
09a0b7a… leo 590 sys.exit(1)
09a0b7a… leo 591
09a0b7a… leo 592 cache_path = Path(cache_dir or os.environ.get("CACHE_DIR"))
09a0b7a… leo 593 if not cache_path.exists():
09a0b7a… leo 594 logging.warning(f"Cache directory does not exist: {cache_path}")
09a0b7a… leo 595 return
09a0b7a… leo 596
09a0b7a… leo 597 try:
09a0b7a… leo 598 from video_processor.utils.api_cache import ApiCache
09a0b7a… leo 599
09a0b7a… leo 600 namespaces = [d.name for d in cache_path.iterdir() if d.is_dir()]
09a0b7a… leo 601 if not namespaces:
09a0b7a… leo 602 logging.info("No cache namespaces found")
09a0b7a… leo 603 return
09a0b7a… leo 604
09a0b7a… leo 605 total_cleared = 0
09a0b7a… leo 606 for namespace in namespaces:
09a0b7a… leo 607 cache = ApiCache(cache_path, namespace)
09a0b7a… leo 608 cleared = cache.clear(older_than if not clear_all else None)
09a0b7a… leo 609 total_cleared += cleared
09a0b7a… leo 610 logging.info(f"Cleared {cleared} entries from {namespace} cache")
09a0b7a… leo 611
09a0b7a… leo 612 logging.info(f"Total cleared: {total_cleared} entries")
09a0b7a… leo 613 except Exception as e:
09a0b7a… leo 614 logging.error(f"Error clearing cache: {e}")
09a0b7a… leo 615 if ctx.obj["verbose"]:
09a0b7a… leo 616 import traceback
09a0b7a… leo 617
09a0b7a… leo 618 traceback.print_exc()
09a0b7a… leo 619 sys.exit(1)
a6b6869… leo 620
a6b6869… leo 621
9b34c98… leo 622 @cli.command("agent-analyze")
829e24a… leo 623 @click.option(
829e24a… leo 624 "--input", "-i", required=True, type=click.Path(exists=True), help="Input video file path"
829e24a… leo 625 )
9b34c98… leo 626 @click.option("--output", "-o", required=True, type=click.Path(), help="Output directory")
9b34c98… leo 627 @click.option(
9b34c98… leo 628 "--depth",
9b34c98… leo 629 type=click.Choice(["basic", "standard", "comprehensive"]),
9b34c98… leo 630 default="standard",
9b34c98… leo 631 help="Initial processing depth (agent may adapt)",
9b34c98… leo 632 )
9b34c98… leo 633 @click.option("--title", type=str, help="Title for the analysis report")
9b34c98… leo 634 @click.option(
9b34c98… leo 635 "--provider",
9b34c98… leo 636 "-p",
0981a08… noreply 637 type=click.Choice(
0981a08… noreply 638 [
0981a08… noreply 639 "auto",
0981a08… noreply 640 "openai",
0981a08… noreply 641 "anthropic",
0981a08… noreply 642 "gemini",
0981a08… noreply 643 "ollama",
0981a08… noreply 644 "azure",
0981a08… noreply 645 "together",
0981a08… noreply 646 "fireworks",
0981a08… noreply 647 "cerebras",
0981a08… noreply 648 "xai",
0981a08… noreply 649 ]
0981a08… noreply 650 ),
9b34c98… leo 651 default="auto",
9b34c98… leo 652 help="API provider",
9b34c98… leo 653 )
9b34c98… leo 654 @click.option("--vision-model", type=str, default=None, help="Override model for vision tasks")
9b34c98… leo 655 @click.option("--chat-model", type=str, default=None, help="Override model for LLM/chat tasks")
9b34c98… leo 656 @click.pass_context
9b34c98… leo 657 def agent_analyze(ctx, input, output, depth, title, provider, vision_model, chat_model):
9b34c98… leo 658 """Agentic video analysis — adaptive, intelligent processing."""
9b34c98… leo 659 from video_processor.agent.orchestrator import AgentOrchestrator
9b34c98… leo 660 from video_processor.output_structure import write_video_manifest
9b34c98… leo 661 from video_processor.providers.manager import ProviderManager
9b34c98… leo 662
9b34c98… leo 663 prov = None if provider == "auto" else provider
9b34c98… leo 664 pm = ProviderManager(vision_model=vision_model, chat_model=chat_model, provider=prov)
9b34c98… leo 665
9b34c98… leo 666 agent = AgentOrchestrator(provider_manager=pm)
9b34c98… leo 667
9b34c98… leo 668 try:
9b34c98… leo 669 manifest = agent.process(
9b34c98… leo 670 input_path=input,
9b34c98… leo 671 output_dir=output,
9b34c98… leo 672 initial_depth=depth,
9b34c98… leo 673 title=title,
9b34c98… leo 674 )
9b34c98… leo 675 write_video_manifest(manifest, output)
9b34c98… leo 676
9b34c98… leo 677 if agent.insights:
9b34c98… leo 678 logging.info("Agent insights:")
9b34c98… leo 679 for insight in agent.insights:
9b34c98… leo 680 logging.info(f" - {insight}")
9b34c98… leo 681
9b34c98… leo 682 logging.info(f"Results at {output}/manifest.json")
9b34c98… leo 683 except Exception as e:
9b34c98… leo 684 logging.error(f"Error: {e}")
9b34c98… leo 685 if ctx.obj["verbose"]:
9b34c98… leo 686 import traceback
9b34c98… leo 687
9b34c98… leo 688 traceback.print_exc()
9b34c98… leo 689 sys.exit(1)
9b34c98… leo 690
9b34c98… leo 691
9b34c98… leo 692 @cli.command()
0981a08… noreply 693 @click.argument("request", required=False, default=None)
0981a08… noreply 694 @click.option("--kb", multiple=True, type=click.Path(exists=True), help="Knowledge base paths")
0981a08… noreply 695 @click.option("--interactive", "-I", is_flag=True, help="Interactive chat mode")
0981a08… noreply 696 @click.option("--export", type=click.Path(), default=None, help="Export artifacts to directory")
0981a08… noreply 697 @click.option(
0981a08… noreply 698 "--provider",
0981a08… noreply 699 "-p",
0981a08… noreply 700 type=click.Choice(
0981a08… noreply 701 [
0981a08… noreply 702 "auto",
0981a08… noreply 703 "openai",
0981a08… noreply 704 "anthropic",
0981a08… noreply 705 "gemini",
0981a08… noreply 706 "ollama",
0981a08… noreply 707 "azure",
0981a08… noreply 708 "together",
0981a08… noreply 709 "fireworks",
0981a08… noreply 710 "cerebras",
0981a08… noreply 711 "xai",
0981a08… noreply 712 ]
0981a08… noreply 713 ),
0981a08… noreply 714 default="auto",
0981a08… noreply 715 help="API provider",
0981a08… noreply 716 )
0981a08… noreply 717 @click.option("--chat-model", type=str, default=None, help="Override model for LLM/chat tasks")
0981a08… noreply 718 @click.pass_context
0981a08… noreply 719 def agent(ctx, request, kb, interactive, export, provider, chat_model):
0981a08… noreply 720 """AI planning agent. Synthesizes knowledge into project plans and artifacts.
0981a08… noreply 721
0981a08… noreply 722 Examples:
0981a08… noreply 723
0981a08… noreply 724 planopticon agent "Create a project plan" --kb ./results
0981a08… noreply 725
0981a08… noreply 726 planopticon agent -I --kb ./videos --kb ./docs
0981a08… noreply 727
0981a08… noreply 728 planopticon agent "Generate a PRD" --export ./output
0981a08… noreply 729 """
0981a08… noreply 730 # Ensure all skills are registered
0981a08… noreply 731 import video_processor.agent.skills # noqa: F401
0981a08… noreply 732 from video_processor.agent.agent_loop import PlanningAgent
0981a08… noreply 733 from video_processor.agent.kb_context import KBContext
0981a08… noreply 734 from video_processor.agent.skills.base import AgentContext
0981a08… noreply 735
0981a08… noreply 736 # Build provider manager
0981a08… noreply 737 pm = None
0981a08… noreply 738 try:
0981a08… noreply 739 from video_processor.providers.manager import ProviderManager
0981a08… noreply 740
0981a08… noreply 741 prov = None if provider == "auto" else provider
0981a08… noreply 742 pm = ProviderManager(chat_model=chat_model, provider=prov)
0981a08… noreply 743 except Exception:
0981a08… noreply 744 if not interactive:
0981a08… noreply 745 click.echo("Warning: could not initialize LLM provider.", err=True)
0981a08… noreply 746
0981a08… noreply 747 # Load knowledge base
0981a08… noreply 748 kb_ctx = KBContext()
0981a08… noreply 749 if kb:
0981a08… noreply 750 for path in kb:
0981a08… noreply 751 kb_ctx.add_source(Path(path))
0981a08… noreply 752 kb_ctx.load(provider_manager=pm)
0981a08… noreply 753 click.echo(kb_ctx.summary())
0981a08… noreply 754 else:
0981a08… noreply 755 # Auto-discover
0981a08… noreply 756 kb_ctx = KBContext.auto_discover(provider_manager=pm)
0981a08… noreply 757 if kb_ctx.sources:
0981a08… noreply 758 click.echo(kb_ctx.summary())
0981a08… noreply 759 else:
0981a08… noreply 760 click.echo("No knowledge base found. Use --kb to specify paths.")
0981a08… noreply 761
0981a08… noreply 762 agent_inst = PlanningAgent(
0981a08… noreply 763 context=AgentContext(
0981a08… noreply 764 knowledge_graph=kb_ctx.knowledge_graph if kb_ctx.sources else None,
0981a08… noreply 765 query_engine=kb_ctx.query_engine if kb_ctx.sources else None,
0981a08… noreply 766 provider_manager=pm,
0981a08… noreply 767 )
0981a08… noreply 768 )
0981a08… noreply 769
0981a08… noreply 770 if interactive:
0981a08… noreply 771 click.echo("\nPlanOpticon Agent (interactive mode)")
0981a08… noreply 772 click.echo("Type your request, or 'quit' to exit.\n")
0981a08… noreply 773 while True:
0981a08… noreply 774 try:
0981a08… noreply 775 line = click.prompt("agent", prompt_suffix="> ")
0981a08… noreply 776 except (KeyboardInterrupt, EOFError):
0981a08… noreply 777 click.echo("\nBye.")
0981a08… noreply 778 break
0981a08… noreply 779 if line.strip().lower() in ("quit", "exit", "q"):
0981a08… noreply 780 click.echo("Bye.")
0981a08… noreply 781 break
0981a08… noreply 782
0981a08… noreply 783 # Check for slash commands
0981a08… noreply 784 if line.strip().startswith("/"):
0981a08… noreply 785 cmd = line.strip()[1:].split()[0]
0981a08… noreply 786 if cmd == "plan":
0981a08… noreply 787 artifacts = agent_inst.execute("Generate a project plan")
0981a08… noreply 788 elif cmd == "skills":
0981a08… noreply 789 from video_processor.agent.skills.base import list_skills
0981a08… noreply 790
0981a08… noreply 791 for s in list_skills():
0981a08… noreply 792 click.echo(f" {s.name}: {s.description}")
0981a08… noreply 793 continue
0981a08… noreply 794 elif cmd == "summary":
0981a08… noreply 795 if kb_ctx.sources:
0981a08… noreply 796 click.echo(kb_ctx.summary())
0981a08… noreply 797 continue
0981a08… noreply 798 else:
0981a08… noreply 799 artifacts = agent_inst.execute(line.strip()[1:])
0981a08… noreply 800
0981a08… noreply 801 for a in artifacts:
0981a08… noreply 802 click.echo(f"\n--- {a.name} ({a.artifact_type}) ---\n")
0981a08… noreply 803 click.echo(a.content)
0981a08… noreply 804 else:
0981a08… noreply 805 response = agent_inst.chat(line)
0981a08… noreply 806 click.echo(f"\n{response}\n")
0981a08… noreply 807 elif request:
0981a08… noreply 808 artifacts = agent_inst.execute(request)
0981a08… noreply 809 if not artifacts:
0981a08… noreply 810 click.echo("No artifacts generated. Try a more specific request.")
0981a08… noreply 811 for artifact in artifacts:
0981a08… noreply 812 click.echo(f"\n--- {artifact.name} ({artifact.artifact_type}) ---\n")
0981a08… noreply 813 click.echo(artifact.content)
0981a08… noreply 814
0981a08… noreply 815 if export:
0981a08… noreply 816 from video_processor.agent.skills.artifact_export import export_artifacts
0981a08… noreply 817
0981a08… noreply 818 export_dir = Path(export)
0981a08… noreply 819 export_artifacts(artifacts, export_dir)
0981a08… noreply 820 click.echo(f"Exported {len(artifacts)} artifacts to {export_dir}/")
0981a08… noreply 821 click.echo(f"Manifest: {export_dir / 'manifest.json'}")
0981a08… noreply 822 else:
0981a08… noreply 823 click.echo("Provide a request or use -I for interactive mode.")
0981a08… noreply 824 click.echo("Example: planopticon agent 'Create a project plan' --kb ./results")
0981a08… noreply 825
0981a08… noreply 826
0981a08… noreply 827 @cli.command()
b363c5b… noreply 828 @click.argument("question", required=False, default=None)
b363c5b… noreply 829 @click.option(
b363c5b… noreply 830 "--db-path",
b363c5b… noreply 831 type=click.Path(),
b363c5b… noreply 832 default=None,
b363c5b… noreply 833 help="Path to knowledge_graph.db or .json (auto-detected if omitted)",
b363c5b… noreply 834 )
b363c5b… noreply 835 @click.option(
b363c5b… noreply 836 "--mode",
b363c5b… noreply 837 type=click.Choice(["direct", "agentic", "auto"]),
b363c5b… noreply 838 default="auto",
b363c5b… noreply 839 help="Query mode: direct (no LLM), agentic (LLM), or auto",
b363c5b… noreply 840 )
b363c5b… noreply 841 @click.option(
b363c5b… noreply 842 "--format",
b363c5b… noreply 843 "output_format",
b363c5b… noreply 844 type=click.Choice(["text", "json", "mermaid"]),
b363c5b… noreply 845 default="text",
b363c5b… noreply 846 help="Output format",
b363c5b… noreply 847 )
b363c5b… noreply 848 @click.option("--interactive", "-I", is_flag=True, help="Enter interactive REPL mode")
b363c5b… noreply 849 @click.option(
b363c5b… noreply 850 "--provider",
b363c5b… noreply 851 "-p",
0981a08… noreply 852 type=click.Choice(
0981a08… noreply 853 [
0981a08… noreply 854 "auto",
0981a08… noreply 855 "openai",
0981a08… noreply 856 "anthropic",
0981a08… noreply 857 "gemini",
0981a08… noreply 858 "ollama",
0981a08… noreply 859 "azure",
0981a08… noreply 860 "together",
0981a08… noreply 861 "fireworks",
0981a08… noreply 862 "cerebras",
0981a08… noreply 863 "xai",
0981a08… noreply 864 ]
0981a08… noreply 865 ),
b363c5b… noreply 866 default="auto",
b363c5b… noreply 867 help="API provider for agentic mode",
b363c5b… noreply 868 )
b363c5b… noreply 869 @click.option("--chat-model", type=str, default=None, help="Override model for agentic mode")
b363c5b… noreply 870 @click.pass_context
b363c5b… noreply 871 def query(ctx, question, db_path, mode, output_format, interactive, provider, chat_model):
b363c5b… noreply 872 """Query a knowledge graph. Runs stats if no question given.
b363c5b… noreply 873
b363c5b… noreply 874 Direct commands recognized in QUESTION: stats, entities, relationships,
4a3c1b4… noreply 875 neighbors, path, clusters, sources, provenance, sql.
4a3c1b4… noreply 876 Natural language questions use agentic mode.
b363c5b… noreply 877
b363c5b… noreply 878 Examples:
b363c5b… noreply 879
b363c5b… noreply 880 planopticon query
b363c5b… noreply 881 planopticon query stats
b363c5b… noreply 882 planopticon query "entities --type technology"
b363c5b… noreply 883 planopticon query "neighbors Alice"
0981a08… noreply 884 planopticon query sources
0981a08… noreply 885 planopticon query "provenance Alice"
b363c5b… noreply 886 planopticon query "What was discussed?"
b363c5b… noreply 887 planopticon query -I
b363c5b… noreply 888 """
b363c5b… noreply 889 from video_processor.integrators.graph_discovery import find_nearest_graph
b363c5b… noreply 890 from video_processor.integrators.graph_query import GraphQueryEngine
b363c5b… noreply 891
b363c5b… noreply 892 # Resolve graph path
b363c5b… noreply 893 if db_path:
b363c5b… noreply 894 graph_path = Path(db_path)
b363c5b… noreply 895 if not graph_path.exists():
b363c5b… noreply 896 click.echo(f"Error: file not found: {db_path}", err=True)
b363c5b… noreply 897 sys.exit(1)
b363c5b… noreply 898 else:
b363c5b… noreply 899 graph_path = find_nearest_graph()
b363c5b… noreply 900 if not graph_path:
b363c5b… noreply 901 click.echo(
b363c5b… noreply 902 "No knowledge graph found. Run 'planopticon analyze' first to generate one,\n"
b363c5b… noreply 903 "or use --db-path to specify a file.",
b363c5b… noreply 904 err=True,
b363c5b… noreply 905 )
b363c5b… noreply 906 sys.exit(1)
b363c5b… noreply 907 click.echo(f"Using: {graph_path}")
b363c5b… noreply 908
b363c5b… noreply 909 # Build provider manager for agentic mode
b363c5b… noreply 910 pm = None
b363c5b… noreply 911 if mode in ("agentic", "auto"):
b363c5b… noreply 912 try:
b363c5b… noreply 913 from video_processor.providers.manager import ProviderManager
b363c5b… noreply 914
b363c5b… noreply 915 prov = None if provider == "auto" else provider
b363c5b… noreply 916 pm = ProviderManager(chat_model=chat_model, provider=prov)
b363c5b… noreply 917 except Exception:
b363c5b… noreply 918 if mode == "agentic":
b363c5b… noreply 919 click.echo("Warning: could not initialize LLM provider for agentic mode.", err=True)
b363c5b… noreply 920
b363c5b… noreply 921 # Create engine
b363c5b… noreply 922 if graph_path.suffix == ".json":
b363c5b… noreply 923 engine = GraphQueryEngine.from_json_path(graph_path, provider_manager=pm)
b363c5b… noreply 924 else:
b363c5b… noreply 925 engine = GraphQueryEngine.from_db_path(graph_path, provider_manager=pm)
b363c5b… noreply 926
b363c5b… noreply 927 if interactive:
b363c5b… noreply 928 _query_repl(engine, output_format)
b363c5b… noreply 929 return
b363c5b… noreply 930
b363c5b… noreply 931 if not question:
b363c5b… noreply 932 question = "stats"
b363c5b… noreply 933
b363c5b… noreply 934 result = _execute_query(engine, question, mode)
b363c5b… noreply 935 _print_result(result, output_format)
b363c5b… noreply 936
b363c5b… noreply 937
b363c5b… noreply 938 def _execute_query(engine, question, mode):
b363c5b… noreply 939 """Parse a question string and execute the appropriate query."""
b363c5b… noreply 940 parts = question.strip().split()
b363c5b… noreply 941 cmd = parts[0].lower() if parts else ""
b363c5b… noreply 942
b363c5b… noreply 943 # Direct commands
b363c5b… noreply 944 if cmd == "stats":
b363c5b… noreply 945 return engine.stats()
b363c5b… noreply 946
b363c5b… noreply 947 if cmd == "entities":
b363c5b… noreply 948 kwargs = _parse_filter_args(parts[1:])
b363c5b… noreply 949 return engine.entities(
b363c5b… noreply 950 name=kwargs.get("name"),
b363c5b… noreply 951 entity_type=kwargs.get("type"),
b363c5b… noreply 952 limit=int(kwargs.get("limit", 50)),
b363c5b… noreply 953 )
b363c5b… noreply 954
b363c5b… noreply 955 if cmd == "relationships":
b363c5b… noreply 956 kwargs = _parse_filter_args(parts[1:])
b363c5b… noreply 957 return engine.relationships(
b363c5b… noreply 958 source=kwargs.get("source"),
b363c5b… noreply 959 target=kwargs.get("target"),
b363c5b… noreply 960 rel_type=kwargs.get("type"),
b363c5b… noreply 961 limit=int(kwargs.get("limit", 50)),
b363c5b… noreply 962 )
b363c5b… noreply 963
b363c5b… noreply 964 if cmd == "neighbors":
b363c5b… noreply 965 entity_name = " ".join(parts[1:]) if len(parts) > 1 else ""
b363c5b… noreply 966 return engine.neighbors(entity_name)
b363c5b… noreply 967
0981a08… noreply 968 if cmd == "sources":
0981a08… noreply 969 return engine.sources()
0981a08… noreply 970
0981a08… noreply 971 if cmd == "provenance":
0981a08… noreply 972 entity_name = " ".join(parts[1:]) if len(parts) > 1 else ""
0981a08… noreply 973 return engine.provenance(entity_name)
4a3c1b4… noreply 974
4a3c1b4… noreply 975 if cmd == "path":
4a3c1b4… noreply 976 if len(parts) < 3:
4a3c1b4… noreply 977 return engine.stats()
4a3c1b4… noreply 978 return engine.shortest_path(start=parts[1], end=parts[2])
4a3c1b4… noreply 979
4a3c1b4… noreply 980 if cmd == "clusters":
4a3c1b4… noreply 981 return engine.clusters()
0981a08… noreply 982
0981a08… noreply 983 if cmd == "sql":
0981a08… noreply 984 sql_query = " ".join(parts[1:])
0981a08… noreply 985 return engine.sql(sql_query)
b363c5b… noreply 986
b363c5b… noreply 987 # Natural language → agentic (or fallback to entity search in direct mode)
b363c5b… noreply 988 if mode == "direct":
b363c5b… noreply 989 return engine.entities(name=question)
b363c5b… noreply 990 return engine.ask(question)
b363c5b… noreply 991
b363c5b… noreply 992
b363c5b… noreply 993 def _parse_filter_args(parts):
b363c5b… noreply 994 """Parse --key value pairs from a split argument list."""
b363c5b… noreply 995 kwargs = {}
b363c5b… noreply 996 i = 0
b363c5b… noreply 997 while i < len(parts):
b363c5b… noreply 998 if parts[i].startswith("--") and i + 1 < len(parts):
b363c5b… noreply 999 key = parts[i][2:]
b363c5b… noreply 1000 kwargs[key] = parts[i + 1]
b363c5b… noreply 1001 i += 2
b363c5b… noreply 1002 else:
b363c5b… noreply 1003 # Treat as name filter
b363c5b… noreply 1004 kwargs.setdefault("name", parts[i])
b363c5b… noreply 1005 i += 1
b363c5b… noreply 1006 return kwargs
b363c5b… noreply 1007
b363c5b… noreply 1008
b363c5b… noreply 1009 def _print_result(result, output_format):
b363c5b… noreply 1010 """Print a QueryResult in the requested format."""
b363c5b… noreply 1011 if output_format == "json":
b363c5b… noreply 1012 click.echo(result.to_json())
b363c5b… noreply 1013 elif output_format == "mermaid":
b363c5b… noreply 1014 click.echo(result.to_mermaid())
b363c5b… noreply 1015 else:
b363c5b… noreply 1016 click.echo(result.to_text())
b363c5b… noreply 1017
b363c5b… noreply 1018
b363c5b… noreply 1019 def _query_repl(engine, output_format):
b363c5b… noreply 1020 """Interactive REPL for querying the knowledge graph."""
b363c5b… noreply 1021 click.echo("PlanOpticon Knowledge Graph REPL")
b363c5b… noreply 1022 click.echo("Type a query, or 'quit' / 'exit' to leave.\n")
b363c5b… noreply 1023 while True:
b363c5b… noreply 1024 try:
b363c5b… noreply 1025 line = click.prompt("query", prompt_suffix="> ")
b363c5b… noreply 1026 except (KeyboardInterrupt, EOFError):
b363c5b… noreply 1027 click.echo("\nBye.")
b363c5b… noreply 1028 break
b363c5b… noreply 1029 line = line.strip()
b363c5b… noreply 1030 if not line:
b363c5b… noreply 1031 continue
b363c5b… noreply 1032 if line.lower() in ("quit", "exit", "q"):
b363c5b… noreply 1033 click.echo("Bye.")
b363c5b… noreply 1034 break
b363c5b… noreply 1035 result = _execute_query(engine, line, "auto")
b363c5b… noreply 1036 _print_result(result, output_format)
b363c5b… noreply 1037 click.echo()
b363c5b… noreply 1038
b363c5b… noreply 1039
b363c5b… noreply 1040 @cli.command()
0981a08… noreply 1041 @click.argument(
0981a08… noreply 1042 "service",
0981a08… noreply 1043 type=click.Choice(
0981a08… noreply 1044 [
0981a08… noreply 1045 "google",
0981a08… noreply 1046 "dropbox",
0981a08… noreply 1047 "zoom",
0981a08… noreply 1048 "notion",
0981a08… noreply 1049 "github",
0981a08… noreply 1050 "microsoft",
0981a08… noreply 1051 ]
0981a08… noreply 1052 ),
0981a08… noreply 1053 )
0981a08… noreply 1054 @click.option("--logout", is_flag=True, help="Clear saved token")
0981a08… noreply 1055 @click.pass_context
0981a08… noreply 1056 def auth(ctx, service, logout):
0981a08… noreply 1057 """Authenticate with a cloud service via OAuth or API key.
0981a08… noreply 1058
0981a08… noreply 1059 Uses OAuth when available, falls back to API keys.
0981a08… noreply 1060 Tokens are saved to ~/.planopticon/ for reuse.
0981a08… noreply 1061
0981a08… noreply 1062 Examples:
0981a08… noreply 1063
0981a08… noreply 1064 planopticon auth google
0981a08… noreply 1065
0981a08… noreply 1066 planopticon auth zoom
0981a08… noreply 1067
0981a08… noreply 1068 planopticon auth github --logout
0981a08… noreply 1069 """
0981a08… noreply 1070 from video_processor.auth import get_auth_manager
0981a08… noreply 1071
0981a08… noreply 1072 manager = get_auth_manager(service)
0981a08… noreply 1073 if not manager:
0981a08… noreply 1074 click.echo(f"Unknown service: {service}", err=True)
0981a08… noreply 1075 sys.exit(1)
0981a08… noreply 1076
0981a08… noreply 1077 if logout:
0981a08… noreply 1078 manager.clear_token()
0981a08… noreply 1079 click.echo(f"Cleared saved {service} token.")
0981a08… noreply 1080 return
0981a08… noreply 1081
0981a08… noreply 1082 result = manager.authenticate()
0981a08… noreply 1083 if result.success:
0981a08… noreply 1084 click.echo(f"{service.title()} authentication successful ({result.method}).")
0981a08… noreply 1085 else:
0981a08… noreply 1086 click.echo(
0981a08… noreply 1087 f"{service.title()} authentication failed: {result.error}",
0981a08… noreply 1088 err=True,
0981a08… noreply 1089 )
0981a08… noreply 1090 sys.exit(1)
0981a08… noreply 1091
0981a08… noreply 1092
0981a08… noreply 1093 @cli.group()
0981a08… noreply 1094 def gws():
0981a08… noreply 1095 """Google Workspace: fetch docs, sheets, and slides via the gws CLI."""
0981a08… noreply 1096 pass
0981a08… noreply 1097
0981a08… noreply 1098
0981a08… noreply 1099 @gws.command("list")
0981a08… noreply 1100 @click.option("--folder-id", type=str, default=None, help="Drive folder ID to list")
0981a08… noreply 1101 @click.option("--query", "-q", type=str, default=None, help="Drive search query")
0981a08… noreply 1102 @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
0981a08… noreply 1103 def gws_list(folder_id, query, as_json):
0981a08… noreply 1104 """List documents in Google Drive.
0981a08… noreply 1105
0981a08… noreply 1106 Examples:
0981a08… noreply 1107
0981a08… noreply 1108 planopticon gws list
0981a08… noreply 1109
0981a08… noreply 1110 planopticon gws list --folder-id 1abc...
0981a08… noreply 1111
0981a08… noreply 1112 planopticon gws list -q "name contains 'PRD'" --json
0981a08… noreply 1113 """
0981a08… noreply 1114 from video_processor.sources.gws_source import GWSSource
0981a08… noreply 1115
0981a08… noreply 1116 source = GWSSource(folder_id=folder_id, query=query)
0981a08… noreply 1117 if not source.authenticate():
0981a08… noreply 1118 click.echo("Error: gws CLI not available or not authenticated.", err=True)
0981a08… noreply 1119 click.echo("Install: npm install -g @googleworkspace/cli", err=True)
0981a08… noreply 1120 click.echo("Auth: gws auth login", err=True)
0981a08… noreply 1121 sys.exit(1)
0981a08… noreply 1122
0981a08… noreply 1123 files = source.list_videos(folder_id=folder_id)
0981a08… noreply 1124 if as_json:
0981a08… noreply 1125 click.echo(json.dumps([f.model_dump() for f in files], indent=2, default=str))
0981a08… noreply 1126 else:
0981a08… noreply 1127 if not files:
0981a08… noreply 1128 click.echo("No documents found.")
0981a08… noreply 1129 return
0981a08… noreply 1130 for f in files:
0981a08… noreply 1131 size = f"{f.size_bytes / 1024:.0f}KB" if f.size_bytes else "—"
0981a08… noreply 1132 click.echo(f" {f.id[:12]}… {size:>8s} {f.mime_type or ''} {f.name}")
0981a08… noreply 1133
0981a08… noreply 1134
0981a08… noreply 1135 @gws.command("fetch")
0981a08… noreply 1136 @click.argument("doc_ids", nargs=-1)
0981a08… noreply 1137 @click.option("--folder-id", type=str, default=None, help="Fetch all docs in a folder")
0981a08… noreply 1138 @click.option("-o", "--output", type=click.Path(), default=None, help="Output directory")
0981a08… noreply 1139 def gws_fetch(doc_ids, folder_id, output):
0981a08… noreply 1140 """Fetch Google Docs/Sheets/Slides as text files.
0981a08… noreply 1141
0981a08… noreply 1142 Examples:
0981a08… noreply 1143
0981a08… noreply 1144 planopticon gws fetch DOC_ID1 DOC_ID2 -o ./docs
0981a08… noreply 1145
0981a08… noreply 1146 planopticon gws fetch --folder-id 1abc... -o ./docs
0981a08… noreply 1147 """
0981a08… noreply 1148 from video_processor.sources.gws_source import GWSSource
0981a08… noreply 1149
0981a08… noreply 1150 source = GWSSource(folder_id=folder_id, doc_ids=list(doc_ids))
0981a08… noreply 1151 if not source.authenticate():
0981a08… noreply 1152 click.echo("Error: gws CLI not available or not authenticated.", err=True)
0981a08… noreply 1153 sys.exit(1)
0981a08… noreply 1154
0981a08… noreply 1155 out_dir = Path(output) if output else Path.cwd() / "gws_docs"
0981a08… noreply 1156 out_dir.mkdir(parents=True, exist_ok=True)
0981a08… noreply 1157
0981a08… noreply 1158 files = source.list_videos(folder_id=folder_id)
0981a08… noreply 1159 if not files:
0981a08… noreply 1160 click.echo("No documents found.")
0981a08… noreply 1161 return
0981a08… noreply 1162
0981a08… noreply 1163 for f in files:
0981a08… noreply 1164 safe_name = f.name.replace("/", "_").replace("\\", "_")
0981a08… noreply 1165 dest = out_dir / f"{safe_name}.txt"
0981a08… noreply 1166 try:
0981a08… noreply 1167 source.download(f, dest)
0981a08… noreply 1168 click.echo(f" ✓ {f.name} → {dest}")
0981a08… noreply 1169 except Exception as e:
0981a08… noreply 1170 click.echo(f" ✗ {f.name}: {e}", err=True)
0981a08… noreply 1171
0981a08… noreply 1172 click.echo(f"\nFetched {len(files)} document(s) to {out_dir}")
0981a08… noreply 1173
0981a08… noreply 1174
0981a08… noreply 1175 @gws.command("ingest")
0981a08… noreply 1176 @click.option("--folder-id", type=str, default=None, help="Drive folder ID")
0981a08… noreply 1177 @click.option("--doc-id", type=str, multiple=True, help="Specific doc IDs (repeatable)")
0981a08… noreply 1178 @click.option("--query", "-q", type=str, default=None, help="Drive search query")
0981a08… noreply 1179 @click.option("-o", "--output", type=click.Path(), default=None, help="Output directory")
0981a08… noreply 1180 @click.option("--db-path", type=click.Path(), default=None, help="Existing DB to merge into")
0981a08… noreply 1181 @click.option(
0981a08… noreply 1182 "-p",
0981a08… noreply 1183 "--provider",
0981a08… noreply 1184 type=click.Choice(
0981a08… noreply 1185 [
0981a08… noreply 1186 "auto",
0981a08… noreply 1187 "openai",
0981a08… noreply 1188 "anthropic",
0981a08… noreply 1189 "gemini",
0981a08… noreply 1190 "ollama",
0981a08… noreply 1191 "azure",
0981a08… noreply 1192 "together",
0981a08… noreply 1193 "fireworks",
0981a08… noreply 1194 "cerebras",
0981a08… noreply 1195 "xai",
0981a08… noreply 1196 ]
0981a08… noreply 1197 ),
0981a08… noreply 1198 default="auto",
0981a08… noreply 1199 help="API provider",
0981a08… noreply 1200 )
0981a08… noreply 1201 @click.option("--chat-model", type=str, default=None, help="Override model for LLM/chat tasks")
0981a08… noreply 1202 @click.pass_context
0981a08… noreply 1203 def gws_ingest(ctx, folder_id, doc_id, query, output, db_path, provider, chat_model):
0981a08… noreply 1204 """Fetch Google Workspace docs and ingest into a knowledge graph.
0981a08… noreply 1205
0981a08… noreply 1206 Combines gws fetch + planopticon ingest in one step.
0981a08… noreply 1207
0981a08… noreply 1208 Examples:
0981a08… noreply 1209
0981a08… noreply 1210 planopticon gws ingest --folder-id 1abc...
0981a08… noreply 1211
0981a08… noreply 1212 planopticon gws ingest --doc-id DOC1 --doc-id DOC2 -o ./results
0981a08… noreply 1213
0981a08… noreply 1214 planopticon gws ingest -q "name contains 'spec'" --db-path existing.db
0981a08… noreply 1215 """
0981a08… noreply 1216 import tempfile
0981a08… noreply 1217
0981a08… noreply 1218 from video_processor.integrators.knowledge_graph import KnowledgeGraph
0981a08… noreply 1219 from video_processor.processors.ingest import ingest_file
0981a08… noreply 1220 from video_processor.providers.manager import ProviderManager
0981a08… noreply 1221 from video_processor.sources.gws_source import GWSSource
0981a08… noreply 1222
0981a08… noreply 1223 source = GWSSource(folder_id=folder_id, doc_ids=list(doc_id), query=query)
0981a08… noreply 1224 if not source.authenticate():
0981a08… noreply 1225 click.echo("Error: gws CLI not available or not authenticated.", err=True)
0981a08… noreply 1226 click.echo("Install: npm install -g @googleworkspace/cli", err=True)
0981a08… noreply 1227 click.echo("Auth: gws auth login", err=True)
0981a08… noreply 1228 sys.exit(1)
0981a08… noreply 1229
0981a08… noreply 1230 # Fetch docs to temp dir
0981a08… noreply 1231 files = source.list_videos(folder_id=folder_id)
0981a08… noreply 1232 if not files:
0981a08… noreply 1233 click.echo("No documents found.")
0981a08… noreply 1234 return
0981a08… noreply 1235
0981a08… noreply 1236 click.echo(f"Found {len(files)} document(s), fetching...")
0981a08… noreply 1237
0981a08… noreply 1238 with tempfile.TemporaryDirectory() as tmp_dir:
0981a08… noreply 1239 tmp_path = Path(tmp_dir)
0981a08… noreply 1240 local_files = []
0981a08… noreply 1241 for f in files:
0981a08… noreply 1242 safe_name = f.name.replace("/", "_").replace("\\", "_")
0981a08… noreply 1243 dest = tmp_path / f"{safe_name}.txt"
0981a08… noreply 1244 try:
0981a08… noreply 1245 source.download(f, dest)
0981a08… noreply 1246 local_files.append(dest)
0981a08… noreply 1247 click.echo(f" ✓ {f.name}")
0981a08… noreply 1248 except Exception as e:
0981a08… noreply 1249 click.echo(f" ✗ {f.name}: {e}", err=True)
0981a08… noreply 1250
0981a08… noreply 1251 if not local_files:
0981a08… noreply 1252 click.echo("No documents fetched successfully.", err=True)
0981a08… noreply 1253 sys.exit(1)
0981a08… noreply 1254
0981a08… noreply 1255 # Set up KG
0981a08… noreply 1256 prov = None if provider == "auto" else provider
0981a08… noreply 1257 pm = ProviderManager(chat_model=chat_model, provider=prov)
0981a08… noreply 1258
0981a08… noreply 1259 if db_path:
0981a08… noreply 1260 kg_path = Path(db_path)
0981a08… noreply 1261 elif output:
0981a08… noreply 1262 out_dir = Path(output)
0981a08… noreply 1263 out_dir.mkdir(parents=True, exist_ok=True)
0981a08… noreply 1264 kg_path = out_dir / "knowledge_graph.db"
0981a08… noreply 1265 else:
0981a08… noreply 1266 kg_path = Path.cwd() / "knowledge_graph.db"
0981a08… noreply 1267
0981a08… noreply 1268 kg_path.parent.mkdir(parents=True, exist_ok=True)
0981a08… noreply 1269 kg = KnowledgeGraph(provider_manager=pm, db_path=kg_path)
0981a08… noreply 1270
0981a08… noreply 1271 total_chunks = 0
0981a08… noreply 1272 for lf in local_files:
0981a08… noreply 1273 try:
0981a08… noreply 1274 count = ingest_file(lf, kg)
0981a08… noreply 1275 total_chunks += count
0981a08… noreply 1276 click.echo(f" Ingested {lf.stem}: {count} chunks")
0981a08… noreply 1277 except Exception as e:
0981a08… noreply 1278 click.echo(f" Failed to ingest {lf.stem}: {e}", err=True)
0981a08… noreply 1279
0981a08… noreply 1280 kg.save(kg_path)
0981a08… noreply 1281 kg.save(kg_path.with_suffix(".json"))
0981a08… noreply 1282
0981a08… noreply 1283 entity_count = kg._store.get_entity_count()
0981a08… noreply 1284 rel_count = kg._store.get_relationship_count()
0981a08… noreply 1285
0981a08… noreply 1286 click.echo("\nIngestion complete:")
0981a08… noreply 1287 click.echo(f" Documents: {len(local_files)}")
0981a08… noreply 1288 click.echo(f" Chunks: {total_chunks}")
0981a08… noreply 1289 click.echo(f" Entities: {entity_count}")
0981a08… noreply 1290 click.echo(f" Relationships: {rel_count}")
0981a08… noreply 1291 click.echo(f" Knowledge graph: {kg_path}")
0981a08… noreply 1292
0981a08… noreply 1293
0981a08… noreply 1294 @cli.group()
0981a08… noreply 1295 def m365():
0981a08… noreply 1296 """Microsoft 365: fetch docs from SharePoint and OneDrive via the m365 CLI."""
0981a08… noreply 1297 pass
0981a08… noreply 1298
0981a08… noreply 1299
0981a08… noreply 1300 @m365.command("list")
0981a08… noreply 1301 @click.option("--web-url", type=str, required=True, help="SharePoint site URL")
0981a08… noreply 1302 @click.option("--folder-url", type=str, required=True, help="Server-relative folder URL")
0981a08… noreply 1303 @click.option("--recursive", is_flag=True, help="Include subfolders")
0981a08… noreply 1304 @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
0981a08… noreply 1305 def m365_list(web_url, folder_url, recursive, as_json):
0981a08… noreply 1306 """List documents in SharePoint or OneDrive.
0981a08… noreply 1307
0981a08… noreply 1308 Examples:
0981a08… noreply 1309
0981a08… noreply 1310 planopticon m365 list --web-url https://contoso.sharepoint.com/sites/proj \\
0981a08… noreply 1311 --folder-url /sites/proj/Shared\\ Documents
0981a08… noreply 1312
0981a08… noreply 1313 planopticon m365 list --web-url URL --folder-url FOLDER --recursive --json
0981a08… noreply 1314 """
0981a08… noreply 1315 from video_processor.sources.m365_source import M365Source
0981a08… noreply 1316
0981a08… noreply 1317 source = M365Source(web_url=web_url, folder_url=folder_url, recursive=recursive)
0981a08… noreply 1318 if not source.authenticate():
0981a08… noreply 1319 click.echo("Error: m365 CLI not available or not logged in.", err=True)
0981a08… noreply 1320 click.echo("Install: npm install -g @pnp/cli-microsoft365", err=True)
0981a08… noreply 1321 click.echo("Auth: m365 login", err=True)
0981a08… noreply 1322 sys.exit(1)
0981a08… noreply 1323
0981a08… noreply 1324 files = source.list_videos()
0981a08… noreply 1325 if as_json:
0981a08… noreply 1326 click.echo(json.dumps([f.model_dump() for f in files], indent=2, default=str))
0981a08… noreply 1327 else:
0981a08… noreply 1328 if not files:
0981a08… noreply 1329 click.echo("No documents found.")
0981a08… noreply 1330 return
0981a08… noreply 1331 for f in files:
0981a08… noreply 1332 size = f"{f.size_bytes / 1024:.0f}KB" if f.size_bytes else "—"
0981a08… noreply 1333 click.echo(f" {f.id[:12]}… {size:>8s} {f.name}")
0981a08… noreply 1334
0981a08… noreply 1335
0981a08… noreply 1336 @m365.command("fetch")
0981a08… noreply 1337 @click.option("--web-url", type=str, required=True, help="SharePoint site URL")
0981a08… noreply 1338 @click.option("--folder-url", type=str, default=None, help="Server-relative folder URL")
0981a08… noreply 1339 @click.option("--file-id", type=str, multiple=True, help="Specific file IDs (repeatable)")
0981a08… noreply 1340 @click.option("-o", "--output", type=click.Path(), default=None, help="Output directory")
0981a08… noreply 1341 def m365_fetch(web_url, folder_url, file_id, output):
0981a08… noreply 1342 """Fetch SharePoint/OneDrive documents as local files.
0981a08… noreply 1343
0981a08… noreply 1344 Examples:
0981a08… noreply 1345
0981a08… noreply 1346 planopticon m365 fetch --web-url URL --folder-url FOLDER -o ./docs
0981a08… noreply 1347
0981a08… noreply 1348 planopticon m365 fetch --web-url URL --file-id ID1 --file-id ID2 -o ./docs
0981a08… noreply 1349 """
0981a08… noreply 1350 from video_processor.sources.m365_source import M365Source
0981a08… noreply 1351
0981a08… noreply 1352 source = M365Source(web_url=web_url, folder_url=folder_url, file_ids=list(file_id))
0981a08… noreply 1353 if not source.authenticate():
0981a08… noreply 1354 click.echo("Error: m365 CLI not available or not logged in.", err=True)
0981a08… noreply 1355 sys.exit(1)
0981a08… noreply 1356
0981a08… noreply 1357 out_dir = Path(output) if output else Path.cwd() / "m365_docs"
0981a08… noreply 1358 out_dir.mkdir(parents=True, exist_ok=True)
0981a08… noreply 1359
0981a08… noreply 1360 files = source.list_videos()
0981a08… noreply 1361 if not files:
0981a08… noreply 1362 click.echo("No documents found.")
0981a08… noreply 1363 return
0981a08… noreply 1364
0981a08… noreply 1365 for f in files:
0981a08… noreply 1366 dest = out_dir / f.name
0981a08… noreply 1367 try:
0981a08… noreply 1368 source.download(f, dest)
0981a08… noreply 1369 click.echo(f" fetched {f.name}")
0981a08… noreply 1370 except Exception as e:
0981a08… noreply 1371 click.echo(f" failed {f.name}: {e}", err=True)
0981a08… noreply 1372
0981a08… noreply 1373 click.echo(f"\nFetched {len(files)} document(s) to {out_dir}")
0981a08… noreply 1374
0981a08… noreply 1375
0981a08… noreply 1376 @m365.command("ingest")
0981a08… noreply 1377 @click.option("--web-url", type=str, required=True, help="SharePoint site URL")
0981a08… noreply 1378 @click.option("--folder-url", type=str, default=None, help="Server-relative folder URL")
0981a08… noreply 1379 @click.option("--file-id", type=str, multiple=True, help="Specific file IDs (repeatable)")
0981a08… noreply 1380 @click.option("-o", "--output", type=click.Path(), default=None, help="Output directory")
0981a08… noreply 1381 @click.option("--db-path", type=click.Path(), default=None, help="Existing DB to merge into")
0981a08… noreply 1382 @click.option(
0981a08… noreply 1383 "-p",
0981a08… noreply 1384 "--provider",
0981a08… noreply 1385 type=click.Choice(
0981a08… noreply 1386 [
0981a08… noreply 1387 "auto",
0981a08… noreply 1388 "openai",
0981a08… noreply 1389 "anthropic",
0981a08… noreply 1390 "gemini",
0981a08… noreply 1391 "ollama",
0981a08… noreply 1392 "azure",
0981a08… noreply 1393 "together",
0981a08… noreply 1394 "fireworks",
0981a08… noreply 1395 "cerebras",
0981a08… noreply 1396 "xai",
0981a08… noreply 1397 ]
0981a08… noreply 1398 ),
0981a08… noreply 1399 default="auto",
0981a08… noreply 1400 help="API provider",
0981a08… noreply 1401 )
0981a08… noreply 1402 @click.option("--chat-model", type=str, default=None, help="Override model for LLM/chat tasks")
0981a08… noreply 1403 @click.pass_context
0981a08… noreply 1404 def m365_ingest(ctx, web_url, folder_url, file_id, output, db_path, provider, chat_model):
0981a08… noreply 1405 """Fetch SharePoint/OneDrive docs and ingest into a knowledge graph.
0981a08… noreply 1406
0981a08… noreply 1407 Examples:
0981a08… noreply 1408
0981a08… noreply 1409 planopticon m365 ingest --web-url URL --folder-url FOLDER
0981a08… noreply 1410
0981a08… noreply 1411 planopticon m365 ingest --web-url URL --file-id ID1 --file-id ID2 -o ./results
0981a08… noreply 1412 """
0981a08… noreply 1413 import tempfile
0981a08… noreply 1414
0981a08… noreply 1415 from video_processor.integrators.knowledge_graph import KnowledgeGraph
0981a08… noreply 1416 from video_processor.processors.ingest import ingest_file
0981a08… noreply 1417 from video_processor.providers.manager import ProviderManager
0981a08… noreply 1418 from video_processor.sources.m365_source import M365Source
0981a08… noreply 1419
0981a08… noreply 1420 source = M365Source(web_url=web_url, folder_url=folder_url, file_ids=list(file_id))
0981a08… noreply 1421 if not source.authenticate():
0981a08… noreply 1422 click.echo("Error: m365 CLI not available or not logged in.", err=True)
0981a08… noreply 1423 click.echo("Install: npm install -g @pnp/cli-microsoft365", err=True)
0981a08… noreply 1424 click.echo("Auth: m365 login", err=True)
0981a08… noreply 1425 sys.exit(1)
0981a08… noreply 1426
0981a08… noreply 1427 files = source.list_videos()
0981a08… noreply 1428 if not files:
0981a08… noreply 1429 click.echo("No documents found.")
0981a08… noreply 1430 return
0981a08… noreply 1431
0981a08… noreply 1432 click.echo(f"Found {len(files)} document(s), fetching...")
0981a08… noreply 1433
0981a08… noreply 1434 with tempfile.TemporaryDirectory() as tmp_dir:
0981a08… noreply 1435 tmp_path = Path(tmp_dir)
0981a08… noreply 1436 local_files = []
0981a08… noreply 1437 for f in files:
0981a08… noreply 1438 dest = tmp_path / f.name
0981a08… noreply 1439 try:
0981a08… noreply 1440 source.download(f, dest)
0981a08… noreply 1441 # Extract text for non-text formats
0981a08… noreply 1442 text_dest = tmp_path / f"{Path(f.name).stem}.txt"
0981a08… noreply 1443 text = source.download_as_text(f)
0981a08… noreply 1444 text_dest.write_text(text, encoding="utf-8")
0981a08… noreply 1445 local_files.append(text_dest)
0981a08… noreply 1446 click.echo(f" fetched {f.name}")
0981a08… noreply 1447 except Exception as e:
0981a08… noreply 1448 click.echo(f" failed {f.name}: {e}", err=True)
0981a08… noreply 1449
0981a08… noreply 1450 if not local_files:
0981a08… noreply 1451 click.echo("No documents fetched successfully.", err=True)
0981a08… noreply 1452 sys.exit(1)
0981a08… noreply 1453
0981a08… noreply 1454 prov = None if provider == "auto" else provider
0981a08… noreply 1455 pm = ProviderManager(chat_model=chat_model, provider=prov)
0981a08… noreply 1456
0981a08… noreply 1457 if db_path:
0981a08… noreply 1458 kg_path = Path(db_path)
0981a08… noreply 1459 elif output:
0981a08… noreply 1460 out_dir = Path(output)
0981a08… noreply 1461 out_dir.mkdir(parents=True, exist_ok=True)
0981a08… noreply 1462 kg_path = out_dir / "knowledge_graph.db"
0981a08… noreply 1463 else:
0981a08… noreply 1464 kg_path = Path.cwd() / "knowledge_graph.db"
0981a08… noreply 1465
0981a08… noreply 1466 kg_path.parent.mkdir(parents=True, exist_ok=True)
0981a08… noreply 1467 kg = KnowledgeGraph(provider_manager=pm, db_path=kg_path)
0981a08… noreply 1468
0981a08… noreply 1469 total_chunks = 0
0981a08… noreply 1470 for lf in local_files:
0981a08… noreply 1471 try:
0981a08… noreply 1472 count = ingest_file(lf, kg)
0981a08… noreply 1473 total_chunks += count
0981a08… noreply 1474 click.echo(f" Ingested {lf.stem}: {count} chunks")
0981a08… noreply 1475 except Exception as e:
0981a08… noreply 1476 click.echo(f" Failed to ingest {lf.stem}: {e}", err=True)
0981a08… noreply 1477
0981a08… noreply 1478 kg.save(kg_path)
0981a08… noreply 1479 kg.save(kg_path.with_suffix(".json"))
0981a08… noreply 1480
0981a08… noreply 1481 entity_count = kg._store.get_entity_count()
0981a08… noreply 1482 rel_count = kg._store.get_relationship_count()
0981a08… noreply 1483
0981a08… noreply 1484 click.echo("\nIngestion complete:")
0981a08… noreply 1485 click.echo(f" Documents: {len(local_files)}")
0981a08… noreply 1486 click.echo(f" Chunks: {total_chunks}")
0981a08… noreply 1487 click.echo(f" Entities: {entity_count}")
0981a08… noreply 1488 click.echo(f" Relationships: {rel_count}")
0981a08… noreply 1489 click.echo(f" Knowledge graph: {kg_path}")
0981a08… noreply 1490
0981a08… noreply 1491
0981a08… noreply 1492 @cli.group()
0981a08… noreply 1493 def export():
0981a08… noreply 1494 """Export knowledge graphs as markdown docs, notes, or CSV."""
0981a08… noreply 1495 pass
0981a08… noreply 1496
0981a08… noreply 1497
0981a08… noreply 1498 @export.command("markdown")
0981a08… noreply 1499 @click.argument("db_path", type=click.Path(exists=True))
0981a08… noreply 1500 @click.option("-o", "--output", type=click.Path(), default=None, help="Output directory")
0981a08… noreply 1501 @click.option(
0981a08… noreply 1502 "--type",
0981a08… noreply 1503 "doc_types",
0981a08… noreply 1504 type=click.Choice(
0981a08… noreply 1505 [
0981a08… noreply 1506 "summary",
0981a08… noreply 1507 "meeting-notes",
0981a08… noreply 1508 "glossary",
0981a08… noreply 1509 "relationship-map",
0981a08… noreply 1510 "status-report",
0981a08… noreply 1511 "entity-index",
0981a08… noreply 1512 "csv",
0981a08… noreply 1513 "all",
0981a08… noreply 1514 ]
0981a08… noreply 1515 ),
0981a08… noreply 1516 multiple=True,
0981a08… noreply 1517 default=("all",),
0981a08… noreply 1518 help="Document types to generate (repeatable)",
0981a08… noreply 1519 )
0981a08… noreply 1520 def export_markdown(db_path, output, doc_types):
0981a08… noreply 1521 """Generate markdown documents from a knowledge graph.
0981a08… noreply 1522
0981a08… noreply 1523 No API key needed — pure template-based generation.
0981a08… noreply 1524
0981a08… noreply 1525 Examples:
0981a08… noreply 1526
0981a08… noreply 1527 planopticon export markdown knowledge_graph.db
0981a08… noreply 1528
0981a08… noreply 1529 planopticon export markdown kg.db -o ./docs --type summary --type glossary
0981a08… noreply 1530
0981a08… noreply 1531 planopticon export markdown kg.db --type meeting-notes --type csv
0981a08… noreply 1532 """
0981a08… noreply 1533 from video_processor.exporters.markdown import generate_all
0981a08… noreply 1534 from video_processor.integrators.knowledge_graph import KnowledgeGraph
0981a08… noreply 1535
0981a08… noreply 1536 db_path = Path(db_path)
0981a08… noreply 1537 out_dir = Path(output) if output else Path.cwd() / "export"
0981a08… noreply 1538
0981a08… noreply 1539 kg = KnowledgeGraph(db_path=db_path)
0981a08… noreply 1540 kg_data = kg.to_dict()
0981a08… noreply 1541
0981a08… noreply 1542 types = None if "all" in doc_types else list(doc_types)
0981a08… noreply 1543 created = generate_all(kg_data, out_dir, doc_types=types)
0981a08… noreply 1544
0981a08… noreply 1545 click.echo(f"Generated {len(created)} files in {out_dir}/")
0981a08… noreply 1546 # Show top-level files (not entity briefs)
0981a08… noreply 1547 for p in sorted(created):
0981a08… noreply 1548 if p.parent == out_dir:
0981a08… noreply 1549 click.echo(f" {p.name}")
0981a08… noreply 1550 entity_count = len([p for p in created if p.parent != out_dir])
0981a08… noreply 1551 if entity_count:
0981a08… noreply 1552 click.echo(f" entities/ ({entity_count} entity briefs)")
0981a08… noreply 1553
0981a08… noreply 1554
0981a08… noreply 1555 @export.command("obsidian")
0981a08… noreply 1556 @click.argument("db_path", type=click.Path(exists=True))
0981a08… noreply 1557 @click.option("-o", "--output", type=click.Path(), default=None, help="Output vault directory")
0981a08… noreply 1558 def export_obsidian(db_path, output):
0981a08… noreply 1559 """Export knowledge graph as an Obsidian vault with frontmatter and wiki-links.
0981a08… noreply 1560
0981a08… noreply 1561 Examples:
0981a08… noreply 1562
0981a08… noreply 1563 planopticon export obsidian knowledge_graph.db -o ./my-vault
0981a08… noreply 1564 """
0981a08… noreply 1565 from video_processor.agent.skills.notes_export import export_to_obsidian
0981a08… noreply 1566 from video_processor.integrators.knowledge_graph import KnowledgeGraph
0981a08… noreply 1567
0981a08… noreply 1568 db_path = Path(db_path)
0981a08… noreply 1569 out_dir = Path(output) if output else Path.cwd() / "obsidian-vault"
0981a08… noreply 1570
0981a08… noreply 1571 kg = KnowledgeGraph(db_path=db_path)
0981a08… noreply 1572 kg_data = kg.to_dict()
0981a08… noreply 1573 created = export_to_obsidian(kg_data, out_dir)
0981a08… noreply 1574
0981a08… noreply 1575 click.echo(f"Exported Obsidian vault: {len(created)} notes in {out_dir}/")
0981a08… noreply 1576
0981a08… noreply 1577
0981a08… noreply 1578 @export.command("notion")
0981a08… noreply 1579 @click.argument("db_path", type=click.Path(exists=True))
0981a08… noreply 1580 @click.option("-o", "--output", type=click.Path(), default=None, help="Output directory")
0981a08… noreply 1581 def export_notion(db_path, output):
0981a08… noreply 1582 """Export knowledge graph as Notion-compatible markdown + CSV database.
0981a08… noreply 1583
0981a08… noreply 1584 Examples:
0981a08… noreply 1585
0981a08… noreply 1586 planopticon export notion knowledge_graph.db -o ./notion-export
0981a08… noreply 1587 """
0981a08… noreply 1588 from video_processor.agent.skills.notes_export import export_to_notion_md
0981a08… noreply 1589 from video_processor.integrators.knowledge_graph import KnowledgeGraph
0981a08… noreply 1590
0981a08… noreply 1591 db_path = Path(db_path)
0981a08… noreply 1592 out_dir = Path(output) if output else Path.cwd() / "notion-export"
0981a08… noreply 1593
0981a08… noreply 1594 kg = KnowledgeGraph(db_path=db_path)
0981a08… noreply 1595 kg_data = kg.to_dict()
0981a08… noreply 1596 created = export_to_notion_md(kg_data, out_dir)
0981a08… noreply 1597
0981a08… noreply 1598 click.echo(f"Exported Notion markdown: {len(created)} files in {out_dir}/")
54d5d79… noreply 1599
54d5d79… noreply 1600
54d5d79… noreply 1601 @export.command("pdf")
54d5d79… noreply 1602 @click.argument("db_path", type=click.Path(exists=True))
54d5d79… noreply 1603 @click.option("-o", "--output", type=click.Path(), default=None, help="Output PDF file path")
54d5d79… noreply 1604 @click.option("--title", type=str, default=None, help="Report title")
54d5d79… noreply 1605 @click.option(
54d5d79… noreply 1606 "--diagrams",
54d5d79… noreply 1607 type=click.Path(exists=True),
54d5d79… noreply 1608 default=None,
54d5d79… noreply 1609 help="Directory with diagram PNGs to embed",
54d5d79… noreply 1610 )
54d5d79… noreply 1611 def export_pdf(db_path, output, title, diagrams):
54d5d79… noreply 1612 """Generate a PDF report from a knowledge graph.
54d5d79… noreply 1613
54d5d79… noreply 1614 Requires: pip install reportlab
54d5d79… noreply 1615
54d5d79… noreply 1616 Examples:
54d5d79… noreply 1617
54d5d79… noreply 1618 planopticon export pdf knowledge_graph.db
54d5d79… noreply 1619
54d5d79… noreply 1620 planopticon export pdf kg.db -o report.pdf --title "Q1 Review"
54d5d79… noreply 1621
54d5d79… noreply 1622 planopticon export pdf kg.db --diagrams ./diagrams/
54d5d79… noreply 1623 """
54d5d79… noreply 1624 from video_processor.exporters.pdf_export import generate_pdf
54d5d79… noreply 1625 from video_processor.integrators.knowledge_graph import KnowledgeGraph
54d5d79… noreply 1626
54d5d79… noreply 1627 db_path = Path(db_path)
54d5d79… noreply 1628 out_path = Path(output) if output else Path.cwd() / "export" / "report.pdf"
54d5d79… noreply 1629 diagrams_path = Path(diagrams) if diagrams else None
54d5d79… noreply 1630
54d5d79… noreply 1631 kg = KnowledgeGraph(db_path=db_path)
54d5d79… noreply 1632 kg_data = kg.to_dict()
54d5d79… noreply 1633
54d5d79… noreply 1634 result = generate_pdf(kg_data, out_path, title=title, diagrams_dir=diagrams_path)
54d5d79… noreply 1635 click.echo(f"Generated PDF: {result}")
54d5d79… noreply 1636
54d5d79… noreply 1637
54d5d79… noreply 1638 @export.command("pptx")
54d5d79… noreply 1639 @click.argument("db_path", type=click.Path(exists=True))
54d5d79… noreply 1640 @click.option("-o", "--output", type=click.Path(), default=None, help="Output PPTX file path")
54d5d79… noreply 1641 @click.option("--title", type=str, default=None, help="Presentation title")
54d5d79… noreply 1642 @click.option(
54d5d79… noreply 1643 "--diagrams",
54d5d79… noreply 1644 type=click.Path(exists=True),
54d5d79… noreply 1645 default=None,
54d5d79… noreply 1646 help="Directory with diagram PNGs to embed",
54d5d79… noreply 1647 )
54d5d79… noreply 1648 def export_pptx(db_path, output, title, diagrams):
54d5d79… noreply 1649 """Generate a PPTX slide deck from a knowledge graph.
54d5d79… noreply 1650
54d5d79… noreply 1651 Requires: pip install python-pptx
54d5d79… noreply 1652
54d5d79… noreply 1653 Examples:
54d5d79… noreply 1654
54d5d79… noreply 1655 planopticon export pptx knowledge_graph.db
54d5d79… noreply 1656
54d5d79… noreply 1657 planopticon export pptx kg.db -o slides.pptx --title "Architecture Overview"
54d5d79… noreply 1658
54d5d79… noreply 1659 planopticon export pptx kg.db --diagrams ./diagrams/
54d5d79… noreply 1660 """
54d5d79… noreply 1661 from video_processor.exporters.pptx_export import generate_pptx
54d5d79… noreply 1662 from video_processor.integrators.knowledge_graph import KnowledgeGraph
54d5d79… noreply 1663
54d5d79… noreply 1664 db_path = Path(db_path)
54d5d79… noreply 1665 out_path = Path(output) if output else Path.cwd() / "export" / "presentation.pptx"
54d5d79… noreply 1666 diagrams_path = Path(diagrams) if diagrams else None
54d5d79… noreply 1667
54d5d79… noreply 1668 kg = KnowledgeGraph(db_path=db_path)
54d5d79… noreply 1669 kg_data = kg.to_dict()
54d5d79… noreply 1670
54d5d79… noreply 1671 result = generate_pptx(kg_data, out_path, title=title, diagrams_dir=diagrams_path)
54d5d79… noreply 1672 click.echo(f"Generated PPTX: {result}")
0981a08… noreply 1673
0981a08… noreply 1674
0981a08… noreply 1675 @export.command("exchange")
0981a08… noreply 1676 @click.argument("db_path", type=click.Path(exists=True))
0981a08… noreply 1677 @click.option(
0981a08… noreply 1678 "-o",
0981a08… noreply 1679 "--output",
0981a08… noreply 1680 type=click.Path(),
0981a08… noreply 1681 default=None,
0981a08… noreply 1682 help="Output JSON file path",
0981a08… noreply 1683 )
0981a08… noreply 1684 @click.option(
0981a08… noreply 1685 "--name",
0981a08… noreply 1686 "project_name",
0981a08… noreply 1687 type=str,
0981a08… noreply 1688 default="Untitled",
0981a08… noreply 1689 help="Project name for the exchange payload",
0981a08… noreply 1690 )
0981a08… noreply 1691 @click.option(
0981a08… noreply 1692 "--description",
0981a08… noreply 1693 "project_desc",
0981a08… noreply 1694 type=str,
0981a08… noreply 1695 default="",
0981a08… noreply 1696 help="Project description",
0981a08… noreply 1697 )
0981a08… noreply 1698 def export_exchange(db_path, output, project_name, project_desc):
0981a08… noreply 1699 """Export a knowledge graph as a PlanOpticonExchange JSON file.
0981a08… noreply 1700
0981a08… noreply 1701 Examples:
0981a08… noreply 1702
0981a08… noreply 1703 planopticon export exchange knowledge_graph.db
0981a08… noreply 1704
0981a08… noreply 1705 planopticon export exchange kg.db -o exchange.json --name "My Project"
0981a08… noreply 1706 """
0981a08… noreply 1707 from video_processor.exchange import PlanOpticonExchange
0981a08… noreply 1708 from video_processor.integrators.knowledge_graph import KnowledgeGraph
0981a08… noreply 1709
0981a08… noreply 1710 db_path = Path(db_path)
0981a08… noreply 1711 kg = KnowledgeGraph(db_path=db_path)
0981a08… noreply 1712 kg_data = kg.to_dict()
0981a08… noreply 1713
0981a08… noreply 1714 ex = PlanOpticonExchange.from_knowledge_graph(
0981a08… noreply 1715 kg_data,
0981a08… noreply 1716 project_name=project_name,
0981a08… noreply 1717 project_description=project_desc,
0981a08… noreply 1718 )
0981a08… noreply 1719
0981a08… noreply 1720 out_path = Path(output) if output else Path.cwd() / "exchange.json"
0981a08… noreply 1721 ex.to_file(out_path)
0981a08… noreply 1722
0981a08… noreply 1723 click.echo(
0981a08… noreply 1724 f"Exported PlanOpticonExchange to {out_path} "
0981a08… noreply 1725 f"({len(ex.entities)} entities, "
0981a08… noreply 1726 f"{len(ex.relationships)} relationships)"
0981a08… noreply 1727 )
0981a08… noreply 1728
0981a08… noreply 1729
0981a08… noreply 1730 @cli.group()
0981a08… noreply 1731 def wiki():
0981a08… noreply 1732 """Generate and push GitHub wikis from knowledge graphs."""
0981a08… noreply 1733 pass
0981a08… noreply 1734
0981a08… noreply 1735
0981a08… noreply 1736 @wiki.command("generate")
0981a08… noreply 1737 @click.argument("db_path", type=click.Path(exists=True))
0981a08… noreply 1738 @click.option("-o", "--output", type=click.Path(), default=None, help="Output directory for wiki")
0981a08… noreply 1739 @click.option("--title", type=str, default="Knowledge Base", help="Wiki title")
0981a08… noreply 1740 def wiki_generate(db_path, output, title):
0981a08… noreply 1741 """Generate a GitHub wiki from a knowledge graph.
0981a08… noreply 1742
0981a08… noreply 1743 Examples:
0981a08… noreply 1744
0981a08… noreply 1745 planopticon wiki generate knowledge_graph.db -o ./wiki
0981a08… noreply 1746
0981a08… noreply 1747 planopticon wiki generate results/kg.db --title "Project Wiki"
0981a08… noreply 1748 """
0981a08… noreply 1749 from video_processor.agent.skills.wiki_generator import generate_wiki, write_wiki
0981a08… noreply 1750 from video_processor.integrators.knowledge_graph import KnowledgeGraph
0981a08… noreply 1751
0981a08… noreply 1752 db_path = Path(db_path)
0981a08… noreply 1753 out_dir = Path(output) if output else Path.cwd() / "wiki"
0981a08… noreply 1754
0981a08… noreply 1755 kg = KnowledgeGraph(db_path=db_path)
0981a08… noreply 1756 kg_data = kg.to_dict()
0981a08… noreply 1757 pages = generate_wiki(kg_data, title=title)
0981a08… noreply 1758 written = write_wiki(pages, out_dir)
0981a08… noreply 1759
0981a08… noreply 1760 click.echo(f"Generated {len(written)} wiki pages in {out_dir}")
0981a08… noreply 1761 for p in sorted(written):
0981a08… noreply 1762 click.echo(f" {p.name}")
0981a08… noreply 1763
0981a08… noreply 1764
0981a08… noreply 1765 @wiki.command("push")
0981a08… noreply 1766 @click.argument("wiki_dir", type=click.Path(exists=True))
0981a08… noreply 1767 @click.argument("repo", type=str)
0981a08… noreply 1768 @click.option("--message", "-m", type=str, default="Update wiki", help="Commit message")
0981a08… noreply 1769 def wiki_push(wiki_dir, repo, message):
0981a08… noreply 1770 """Push generated wiki pages to a GitHub wiki repo.
0981a08… noreply 1771
0981a08… noreply 1772 REPO should be in 'owner/repo' format.
0981a08… noreply 1773
0981a08… noreply 1774 Examples:
0981a08… noreply 1775
0981a08… noreply 1776 planopticon wiki push ./wiki ConflictHQ/PlanOpticon
0981a08… noreply 1777
0981a08… noreply 1778 planopticon wiki push ./wiki owner/repo -m "Add entity pages"
0981a08… noreply 1779 """
0981a08… noreply 1780 from video_processor.agent.skills.wiki_generator import push_wiki
0981a08… noreply 1781
0981a08… noreply 1782 wiki_dir = Path(wiki_dir)
0981a08… noreply 1783 success = push_wiki(wiki_dir, repo, message=message)
0981a08… noreply 1784 if success:
0981a08… noreply 1785 click.echo(f"Wiki pushed to https://github.com/{repo}/wiki")
0981a08… noreply 1786 else:
0981a08… noreply 1787 click.echo("Wiki push failed. Check auth and repo permissions.", err=True)
0981a08… noreply 1788 sys.exit(1)
0981a08… noreply 1789
0981a08… noreply 1790
0981a08… noreply 1791 @cli.group()
0981a08… noreply 1792 def recordings():
0981a08… noreply 1793 """Fetch meeting recordings from Zoom, Teams, and Google Meet."""
0981a08… noreply 1794 pass
0981a08… noreply 1795
0981a08… noreply 1796
0981a08… noreply 1797 @recordings.command("zoom-list")
0981a08… noreply 1798 @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
0981a08… noreply 1799 def recordings_zoom_list(as_json):
0981a08… noreply 1800 """List Zoom cloud recordings.
0981a08… noreply 1801
0981a08… noreply 1802 Requires ZOOM_CLIENT_ID (and optionally ZOOM_CLIENT_SECRET,
0981a08… noreply 1803 ZOOM_ACCOUNT_ID) environment variables.
0981a08… noreply 1804
0981a08… noreply 1805 Examples:
0981a08… noreply 1806
0981a08… noreply 1807 planopticon recordings zoom-list
0981a08… noreply 1808
0981a08… noreply 1809 planopticon recordings zoom-list --json
0981a08… noreply 1810 """
0981a08… noreply 1811 from video_processor.sources.zoom_source import ZoomSource
0981a08… noreply 1812
0981a08… noreply 1813 source = ZoomSource()
0981a08… noreply 1814 if not source.authenticate():
0981a08… noreply 1815 click.echo("Zoom authentication failed.", err=True)
0981a08… noreply 1816 sys.exit(1)
0981a08… noreply 1817
0981a08… noreply 1818 files = source.list_videos()
0981a08… noreply 1819 if as_json:
0981a08… noreply 1820 click.echo(json.dumps([f.__dict__ for f in files], indent=2, default=str))
0981a08… noreply 1821 else:
0981a08… noreply 1822 click.echo(f"Found {len(files)} recording(s):")
0981a08… noreply 1823 for f in files:
0981a08… noreply 1824 size = f"{f.size_bytes // 1_000_000} MB" if f.size_bytes else "unknown"
0981a08… noreply 1825 click.echo(f" {f.name} ({size}) {f.modified_at or ''}")
0981a08… noreply 1826
0981a08… noreply 1827
0981a08… noreply 1828 @recordings.command("teams-list")
0981a08… noreply 1829 @click.option("--user-id", default="me", help="Microsoft user ID")
0981a08… noreply 1830 @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
0981a08… noreply 1831 def recordings_teams_list(user_id, as_json):
0981a08… noreply 1832 """List Teams meeting recordings via the m365 CLI.
0981a08… noreply 1833
0981a08… noreply 1834 Requires: npm install -g @pnp/cli-microsoft365 && m365 login
0981a08… noreply 1835
0981a08… noreply 1836 Examples:
0981a08… noreply 1837
0981a08… noreply 1838 planopticon recordings teams-list
0981a08… noreply 1839
0981a08… noreply 1840 planopticon recordings teams-list --json
0981a08… noreply 1841 """
0981a08… noreply 1842 from video_processor.sources.teams_recording_source import (
0981a08… noreply 1843 TeamsRecordingSource,
0981a08… noreply 1844 )
0981a08… noreply 1845
0981a08… noreply 1846 source = TeamsRecordingSource(user_id=user_id)
0981a08… noreply 1847 if not source.authenticate():
0981a08… noreply 1848 click.echo("Teams authentication failed.", err=True)
0981a08… noreply 1849 sys.exit(1)
0981a08… noreply 1850
0981a08… noreply 1851 files = source.list_videos()
0981a08… noreply 1852 if as_json:
0981a08… noreply 1853 click.echo(json.dumps([f.__dict__ for f in files], indent=2, default=str))
0981a08… noreply 1854 else:
0981a08… noreply 1855 click.echo(f"Found {len(files)} recording(s):")
0981a08… noreply 1856 for f in files:
0981a08… noreply 1857 click.echo(f" {f.name} {f.modified_at or ''}")
0981a08… noreply 1858
0981a08… noreply 1859
0981a08… noreply 1860 @recordings.command("meet-list")
0981a08… noreply 1861 @click.option("--folder-id", default=None, help="Drive folder ID")
0981a08… noreply 1862 @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
0981a08… noreply 1863 def recordings_meet_list(folder_id, as_json):
0981a08… noreply 1864 """List Google Meet recordings in Drive via the gws CLI.
0981a08… noreply 1865
0981a08… noreply 1866 Requires: npm install -g @googleworkspace/cli && gws auth login
0981a08… noreply 1867
0981a08… noreply 1868 Examples:
0981a08… noreply 1869
0981a08… noreply 1870 planopticon recordings meet-list
0981a08… noreply 1871
0981a08… noreply 1872 planopticon recordings meet-list --folder-id abc123
0981a08… noreply 1873 """
0981a08… noreply 1874 from video_processor.sources.meet_recording_source import (
0981a08… noreply 1875 MeetRecordingSource,
0981a08… noreply 1876 )
0981a08… noreply 1877
0981a08… noreply 1878 source = MeetRecordingSource(drive_folder_id=folder_id)
0981a08… noreply 1879 if not source.authenticate():
0981a08… noreply 1880 click.echo("Google Meet authentication failed.", err=True)
0981a08… noreply 1881 sys.exit(1)
0981a08… noreply 1882
0981a08… noreply 1883 files = source.list_videos()
0981a08… noreply 1884 if as_json:
0981a08… noreply 1885 click.echo(json.dumps([f.__dict__ for f in files], indent=2, default=str))
0981a08… noreply 1886 else:
0981a08… noreply 1887 click.echo(f"Found {len(files)} recording(s):")
0981a08… noreply 1888 for f in files:
0981a08… noreply 1889 size = f"{f.size_bytes // 1_000_000} MB" if f.size_bytes else "unknown"
0981a08… noreply 1890 click.echo(f" {f.name} ({size}) {f.modified_at or ''}")
0981a08… noreply 1891
0981a08… noreply 1892
0981a08… noreply 1893 @cli.group()
0981a08… noreply 1894 def kg():
0981a08… noreply 1895 """Knowledge graph utilities: convert, sync, and inspect."""
0981a08… noreply 1896 pass
0981a08… noreply 1897
0981a08… noreply 1898
0981a08… noreply 1899 @kg.command()
0981a08… noreply 1900 @click.argument("source_path", type=click.Path(exists=True))
0981a08… noreply 1901 @click.argument("dest_path", type=click.Path())
0981a08… noreply 1902 def convert(source_path, dest_path):
0981a08… noreply 1903 """Convert a knowledge graph between formats.
0981a08… noreply 1904
0981a08… noreply 1905 Supports .db (SQLite) and .json. The output format is inferred from DEST_PATH extension.
0981a08… noreply 1906
0981a08… noreply 1907 Examples:
0981a08… noreply 1908
0981a08… noreply 1909 planopticon kg convert results/knowledge_graph.db output.json
0981a08… noreply 1910 planopticon kg convert knowledge_graph.json knowledge_graph.db
0981a08… noreply 1911 """
0981a08… noreply 1912 from video_processor.integrators.graph_store import InMemoryStore, SQLiteStore
0981a08… noreply 1913
0981a08… noreply 1914 source_path = Path(source_path)
0981a08… noreply 1915 dest_path = Path(dest_path)
0981a08… noreply 1916
0981a08… noreply 1917 if source_path.suffix == dest_path.suffix:
0981a08… noreply 1918 click.echo(f"Source and destination are the same format ({source_path.suffix}).", err=True)
0981a08… noreply 1919 sys.exit(1)
0981a08… noreply 1920
0981a08… noreply 1921 # Load source
0981a08… noreply 1922 if source_path.suffix == ".db":
0981a08… noreply 1923 src_store = SQLiteStore(source_path)
0981a08… noreply 1924 elif source_path.suffix == ".json":
0981a08… noreply 1925 data = json.loads(source_path.read_text())
0981a08… noreply 1926 src_store = InMemoryStore()
0981a08… noreply 1927 for node in data.get("nodes", []):
0981a08… noreply 1928 descs = node.get("descriptions", [])
0981a08… noreply 1929 if isinstance(descs, set):
0981a08… noreply 1930 descs = list(descs)
0981a08… noreply 1931 src_store.merge_entity(node.get("name", ""), node.get("type", "concept"), descs)
0981a08… noreply 1932 for occ in node.get("occurrences", []):
0981a08… noreply 1933 src_store.add_occurrence(
0981a08… noreply 1934 node.get("name", ""),
0981a08… noreply 1935 occ.get("source", ""),
0981a08… noreply 1936 occ.get("timestamp"),
0981a08… noreply 1937 occ.get("text"),
0981a08… noreply 1938 )
0981a08… noreply 1939 for rel in data.get("relationships", []):
0981a08… noreply 1940 src_store.add_relationship(
0981a08… noreply 1941 rel.get("source", ""),
0981a08… noreply 1942 rel.get("target", ""),
0981a08… noreply 1943 rel.get("type", "related_to"),
0981a08… noreply 1944 content_source=rel.get("content_source"),
0981a08… noreply 1945 timestamp=rel.get("timestamp"),
0981a08… noreply 1946 )
0981a08… noreply 1947 else:
0981a08… noreply 1948 click.echo(f"Unsupported source format: {source_path.suffix}", err=True)
0981a08… noreply 1949 sys.exit(1)
0981a08… noreply 1950
0981a08… noreply 1951 # Write destination
0981a08… noreply 1952 from video_processor.integrators.knowledge_graph import KnowledgeGraph
0981a08… noreply 1953
0981a08… noreply 1954 kg_obj = KnowledgeGraph(store=src_store)
0981a08… noreply 1955 kg_obj.save(dest_path)
0981a08… noreply 1956
0981a08… noreply 1957 e_count = src_store.get_entity_count()
0981a08… noreply 1958 r_count = src_store.get_relationship_count()
0981a08… noreply 1959 click.echo(
0981a08… noreply 1960 f"Converted {source_path} → {dest_path} ({e_count} entities, {r_count} relationships)"
0981a08… noreply 1961 )
0981a08… noreply 1962
0981a08… noreply 1963 if hasattr(src_store, "close"):
0981a08… noreply 1964 src_store.close()
0981a08… noreply 1965
0981a08… noreply 1966
0981a08… noreply 1967 @kg.command()
0981a08… noreply 1968 @click.argument("db_path", type=click.Path(exists=True))
0981a08… noreply 1969 @click.argument("json_path", type=click.Path(), required=False, default=None)
0981a08… noreply 1970 @click.option(
0981a08… noreply 1971 "--direction",
0981a08… noreply 1972 type=click.Choice(["db-to-json", "json-to-db", "auto"]),
0981a08… noreply 1973 default="auto",
0981a08… noreply 1974 help="Sync direction. 'auto' picks the newer file as source.",
0981a08… noreply 1975 )
0981a08… noreply 1976 def sync(db_path, json_path, direction):
0981a08… noreply 1977 """Sync a .db and .json knowledge graph, updating the stale one.
0981a08… noreply 1978
0981a08… noreply 1979 If JSON_PATH is omitted, uses the same name with .json extension.
0981a08… noreply 1980
0981a08… noreply 1981 Examples:
0981a08… noreply 1982
0981a08… noreply 1983 planopticon kg sync results/knowledge_graph.db
0981a08… noreply 1984 planopticon kg sync knowledge_graph.db knowledge_graph.json --direction db-to-json
0981a08… noreply 1985 """
0981a08… noreply 1986 db_path = Path(db_path)
0981a08… noreply 1987 if json_path is None:
0981a08… noreply 1988 json_path = db_path.with_suffix(".json")
0981a08… noreply 1989 else:
0981a08… noreply 1990 json_path = Path(json_path)
0981a08… noreply 1991
0981a08… noreply 1992 if direction == "auto":
0981a08… noreply 1993 if not json_path.exists():
0981a08… noreply 1994 direction = "db-to-json"
0981a08… noreply 1995 elif not db_path.exists():
0981a08… noreply 1996 direction = "json-to-db"
0981a08… noreply 1997 else:
0981a08… noreply 1998 db_mtime = db_path.stat().st_mtime
0981a08… noreply 1999 json_mtime = json_path.stat().st_mtime
0981a08… noreply 2000 direction = "db-to-json" if db_mtime >= json_mtime else "json-to-db"
0981a08… noreply 2001
0981a08… noreply 2002 from video_processor.integrators.knowledge_graph import KnowledgeGraph
0981a08… noreply 2003
0981a08… noreply 2004 if direction == "db-to-json":
0981a08… noreply 2005 kg_obj = KnowledgeGraph(db_path=db_path)
0981a08… noreply 2006 kg_obj.save(json_path)
0981a08… noreply 2007 click.echo(f"Synced {db_path} → {json_path}")
0981a08… noreply 2008 else:
0981a08… noreply 2009 data = json.loads(json_path.read_text())
0981a08… noreply 2010 kg_obj = KnowledgeGraph.from_dict(data, db_path=db_path)
0981a08… noreply 2011 # Force write to db by saving
0981a08… noreply 2012 kg_obj.save(db_path)
0981a08… noreply 2013 click.echo(f"Synced {json_path} → {db_path}")
0981a08… noreply 2014
0981a08… noreply 2015 click.echo(
0981a08… noreply 2016 f" {kg_obj._store.get_entity_count()} entities, "
0981a08… noreply 2017 f"{kg_obj._store.get_relationship_count()} relationships"
0981a08… noreply 2018 )
0981a08… noreply 2019
0981a08… noreply 2020
0981a08… noreply 2021 @kg.command()
0981a08… noreply 2022 @click.argument("path", type=click.Path(exists=True))
0981a08… noreply 2023 def inspect(path):
0981a08… noreply 2024 """Show summary stats for a knowledge graph file (.db or .json)."""
0981a08… noreply 2025 from video_processor.integrators.graph_discovery import describe_graph
0981a08… noreply 2026
0981a08… noreply 2027 path = Path(path)
0981a08… noreply 2028 info = describe_graph(path)
0981a08… noreply 2029 click.echo(f"File: {path}")
0981a08… noreply 2030 click.echo(f"Store: {info['store_type']}")
0981a08… noreply 2031 click.echo(f"Entities: {info['entity_count']}")
0981a08… noreply 2032 click.echo(f"Relationships: {info['relationship_count']}")
0981a08… noreply 2033 if info["entity_types"]:
0981a08… noreply 2034 click.echo("Entity types:")
0981a08… noreply 2035 for t, count in sorted(info["entity_types"].items(), key=lambda x: -x[1]):
0981a08… noreply 2036 click.echo(f" {t}: {count}")
0981a08… noreply 2037
0981a08… noreply 2038
0981a08… noreply 2039 @kg.command()
0981a08… noreply 2040 @click.argument("db_path", type=click.Path(exists=True))
0981a08… noreply 2041 @click.option("--provider", "-p", type=str, default="auto")
0981a08… noreply 2042 @click.option("--chat-model", type=str, default=None)
0981a08… noreply 2043 @click.option(
0981a08… noreply 2044 "--format",
0981a08… noreply 2045 "output_format",
0981a08… noreply 2046 type=click.Choice(["text", "json"]),
0981a08… noreply 2047 default="text",
0981a08… noreply 2048 )
0981a08… noreply 2049 @click.pass_context
0981a08… noreply 2050 def classify(ctx, db_path, provider, chat_model, output_format):
0981a08… noreply 2051 """Classify knowledge graph entities into planning taxonomy types.
0981a08… noreply 2052
0981a08… noreply 2053 Examples:\n
0981a08… noreply 2054 planopticon kg classify results/knowledge_graph.db\n
0981a08… noreply 2055 planopticon kg classify results/knowledge_graph.db --format json
0981a08… noreply 2056 """
0981a08… noreply 2057 from video_processor.integrators.graph_store import create_store
0981a08… noreply 2058 from video_processor.integrators.taxonomy import TaxonomyClassifier
0981a08… noreply 2059
0981a08… noreply 2060 db_path = Path(db_path)
0981a08… noreply 2061 store = create_store(db_path)
0981a08… noreply 2062 entities = store.get_all_entities()
0981a08… noreply 2063 relationships = store.get_all_relationships()
0981a08… noreply 2064
0981a08… noreply 2065 pm = None
0981a08… noreply 2066 if provider != "none":
0981a08… noreply 2067 try:
0981a08… noreply 2068 from video_processor.providers.manager import ProviderManager
0981a08… noreply 2069
0981a08… noreply 2070 pm = ProviderManager(provider=provider if provider != "auto" else None)
0981a08… noreply 2071 if chat_model:
0981a08… noreply 2072 pm.chat_model = chat_model
0981a08… noreply 2073 except Exception:
0981a08… noreply 2074 pm = None # fall back to heuristic-only
0981a08… noreply 2075
0981a08… noreply 2076 classifier = TaxonomyClassifier(provider_manager=pm)
0981a08… noreply 2077 planning_entities = classifier.classify_entities(entities, relationships)
0981a08… noreply 2078
0981a08… noreply 2079 if output_format == "json":
0981a08… noreply 2080 click.echo(
0981a08… noreply 2081 json.dumps(
0981a08… noreply 2082 [pe.model_dump() for pe in planning_entities],
0981a08… noreply 2083 indent=2,
0981a08… noreply 2084 )
0981a08… noreply 2085 )
0981a08… noreply 2086 else:
0981a08… noreply 2087 if not planning_entities:
0981a08… noreply 2088 click.echo("No entities matched planning taxonomy types.")
0981a08… noreply 2089 return
0981a08… noreply 2090 workstreams = classifier.organize_by_workstream(planning_entities)
0981a08… noreply 2091 for group_name, items in sorted(workstreams.items()):
0981a08… noreply 2092 click.echo(f"\n{group_name.upper()} ({len(items)})")
0981a08… noreply 2093 for pe in items:
0981a08… noreply 2094 priority_str = f" [{pe.priority}]" if pe.priority else ""
0981a08… noreply 2095 click.echo(f" - {pe.name}{priority_str}")
0981a08… noreply 2096 if pe.description:
0981a08… noreply 2097 click.echo(f" {pe.description}")
0981a08… noreply 2098
0981a08… noreply 2099 store.close()
0981a08… noreply 2100
0981a08… noreply 2101
0981a08… noreply 2102 @kg.command("from-exchange")
0981a08… noreply 2103 @click.argument("exchange_path", type=click.Path(exists=True))
0981a08… noreply 2104 @click.option(
0981a08… noreply 2105 "-o",
0981a08… noreply 2106 "--output",
0981a08… noreply 2107 "db_path",
0981a08… noreply 2108 type=click.Path(),
0981a08… noreply 2109 default=None,
0981a08… noreply 2110 help="Output .db file path",
0981a08… noreply 2111 )
0981a08… noreply 2112 def kg_from_exchange(exchange_path, db_path):
0981a08… noreply 2113 """Import a PlanOpticonExchange JSON file into a knowledge graph .db.
0981a08… noreply 2114
0981a08… noreply 2115 Examples:
0981a08… noreply 2116
0981a08… noreply 2117 planopticon kg from-exchange exchange.json
0981a08… noreply 2118
0981a08… noreply 2119 planopticon kg from-exchange exchange.json -o project.db
0981a08… noreply 2120 """
0981a08… noreply 2121 from video_processor.exchange import PlanOpticonExchange
0981a08… noreply 2122 from video_processor.integrators.knowledge_graph import KnowledgeGraph
0981a08… noreply 2123
0981a08… noreply 2124 ex = PlanOpticonExchange.from_file(exchange_path)
0981a08… noreply 2125
0981a08… noreply 2126 kg_dict = {
0981a08… noreply 2127 "nodes": [e.model_dump() for e in ex.entities],
0981a08… noreply 2128 "relationships": [r.model_dump() for r in ex.relationships],
0981a08… noreply 2129 "sources": [s.model_dump() for s in ex.sources],
0981a08… noreply 2130 }
0981a08… noreply 2131
0981a08… noreply 2132 out = Path(db_path) if db_path else Path.cwd() / "knowledge_graph.db"
0981a08… noreply 2133 kg_obj = KnowledgeGraph.from_dict(kg_dict, db_path=out)
0981a08… noreply 2134 kg_obj.save(out)
0981a08… noreply 2135
0981a08… noreply 2136 click.echo(
0981a08… noreply 2137 f"Imported exchange into {out} "
0981a08… noreply 2138 f"({len(ex.entities)} entities, "
0981a08… noreply 2139 f"{len(ex.relationships)} relationships)"
0981a08… noreply 2140 )
0981a08… noreply 2141
0981a08… noreply 2142
0981a08… noreply 2143 @cli.command()
0981a08… noreply 2144 @click.option(
0981a08… noreply 2145 "--kb",
0981a08… noreply 2146 multiple=True,
0981a08… noreply 2147 type=click.Path(exists=True),
0981a08… noreply 2148 help="Knowledge base paths",
0981a08… noreply 2149 )
0981a08… noreply 2150 @click.option(
0981a08… noreply 2151 "--provider",
0981a08… noreply 2152 "-p",
0981a08… noreply 2153 type=str,
0981a08… noreply 2154 default="auto",
0981a08… noreply 2155 help="LLM provider (auto, openai, anthropic, ...)",
0981a08… noreply 2156 )
0981a08… noreply 2157 @click.option(
0981a08… noreply 2158 "--chat-model",
0981a08… noreply 2159 type=str,
0981a08… noreply 2160 default=None,
0981a08… noreply 2161 help="Chat model override",
0981a08… noreply 2162 )
0981a08… noreply 2163 @click.pass_context
0981a08… noreply 2164 def companion(ctx, kb, provider, chat_model):
0981a08… noreply 2165 """Interactive planning companion with workspace awareness.
0981a08… noreply 2166
0981a08… noreply 2167 Examples:
0981a08… noreply 2168
0981a08… noreply 2169 planopticon companion
0981a08… noreply 2170
0981a08… noreply 2171 planopticon companion --kb ./results
0981a08… noreply 2172
0981a08… noreply 2173 planopticon companion -p anthropic
0981a08… noreply 2174 """
0981a08… noreply 2175 from video_processor.cli.companion import CompanionREPL
0981a08… noreply 2176
0981a08… noreply 2177 repl = CompanionREPL(
0981a08… noreply 2178 kb_paths=list(kb),
0981a08… noreply 2179 provider=provider,
0981a08… noreply 2180 chat_model=chat_model,
0981a08… noreply 2181 )
0981a08… noreply 2182 repl.run()
ecf907c… leo 2183
ecf907c… leo 2184
ecf907c… leo 2185 def _interactive_menu(ctx):
ecf907c… leo 2186 """Show an interactive menu when planopticon is run with no arguments."""
ecf907c… leo 2187 click.echo()
ecf907c… leo 2188 click.echo(" PlanOpticon v0.2.0")
ecf907c… leo 2189 click.echo(" Comprehensive Video Analysis & Knowledge Extraction")
ecf907c… leo 2190 click.echo()
ecf907c… leo 2191 click.echo(" 1. Analyze a video")
ecf907c… leo 2192 click.echo(" 2. Batch process a folder")
ecf907c… leo 2193 click.echo(" 3. List available models")
ecf907c… leo 2194 click.echo(" 4. Authenticate cloud service")
ecf907c… leo 2195 click.echo(" 5. Clear cache")
ecf907c… leo 2196 click.echo(" 6. Show help")
b363c5b… noreply 2197 click.echo(" 7. Query knowledge graph")
ecf907c… leo 2198 click.echo()
ecf907c… leo 2199
b363c5b… noreply 2200 choice = click.prompt(" Select an option", type=click.IntRange(1, 7))
ecf907c… leo 2201
ecf907c… leo 2202 if choice == 1:
ecf907c… leo 2203 input_path = click.prompt(" Video file path", type=click.Path(exists=True))
ecf907c… leo 2204 output_dir = click.prompt(" Output directory", type=click.Path())
ecf907c… leo 2205 depth = click.prompt(
ecf907c… leo 2206 " Processing depth",
ecf907c… leo 2207 type=click.Choice(["basic", "standard", "comprehensive"]),
ecf907c… leo 2208 default="standard",
ecf907c… leo 2209 )
ecf907c… leo 2210 provider = click.prompt(
ecf907c… leo 2211 " Provider",
0981a08… noreply 2212 type=click.Choice(
0981a08… noreply 2213 [
0981a08… noreply 2214 "auto",
0981a08… noreply 2215 "openai",
0981a08… noreply 2216 "anthropic",
0981a08… noreply 2217 "gemini",
0981a08… noreply 2218 "ollama",
0981a08… noreply 2219 "azure",
0981a08… noreply 2220 "together",
0981a08… noreply 2221 "fireworks",
0981a08… noreply 2222 "cerebras",
0981a08… noreply 2223 "xai",
0981a08… noreply 2224 ]
0981a08… noreply 2225 ),
ecf907c… leo 2226 default="auto",
ecf907c… leo 2227 )
ecf907c… leo 2228 ctx.invoke(
ecf907c… leo 2229 analyze,
ecf907c… leo 2230 input=input_path,
ecf907c… leo 2231 output=output_dir,
ecf907c… leo 2232 depth=depth,
ecf907c… leo 2233 focus=None,
ecf907c… leo 2234 use_gpu=False,
ecf907c… leo 2235 sampling_rate=0.5,
ecf907c… leo 2236 change_threshold=0.15,
ecf907c… leo 2237 periodic_capture=30.0,
ecf907c… leo 2238 title=None,
ecf907c… leo 2239 provider=provider,
ecf907c… leo 2240 vision_model=None,
ecf907c… leo 2241 chat_model=None,
ecf907c… leo 2242 )
ecf907c… leo 2243
ecf907c… leo 2244 elif choice == 2:
ecf907c… leo 2245 input_dir = click.prompt(" Video directory", type=click.Path(exists=True))
ecf907c… leo 2246 output_dir = click.prompt(" Output directory", type=click.Path())
ecf907c… leo 2247 depth = click.prompt(
ecf907c… leo 2248 " Processing depth",
ecf907c… leo 2249 type=click.Choice(["basic", "standard", "comprehensive"]),
ecf907c… leo 2250 default="standard",
ecf907c… leo 2251 )
ecf907c… leo 2252 provider = click.prompt(
ecf907c… leo 2253 " Provider",
0981a08… noreply 2254 type=click.Choice(
0981a08… noreply 2255 [
0981a08… noreply 2256 "auto",
0981a08… noreply 2257 "openai",
0981a08… noreply 2258 "anthropic",
0981a08… noreply 2259 "gemini",
0981a08… noreply 2260 "ollama",
0981a08… noreply 2261 "azure",
0981a08… noreply 2262 "together",
0981a08… noreply 2263 "fireworks",
0981a08… noreply 2264 "cerebras",
0981a08… noreply 2265 "xai",
0981a08… noreply 2266 ]
0981a08… noreply 2267 ),
ecf907c… leo 2268 default="auto",
ecf907c… leo 2269 )
ecf907c… leo 2270 ctx.invoke(
ecf907c… leo 2271 batch,
ecf907c… leo 2272 input_dir=input_dir,
ecf907c… leo 2273 output=output_dir,
ecf907c… leo 2274 depth=depth,
ecf907c… leo 2275 pattern="*.mp4,*.mkv,*.avi,*.mov,*.webm",
ecf907c… leo 2276 title="Batch Processing Results",
ecf907c… leo 2277 provider=provider,
ecf907c… leo 2278 vision_model=None,
ecf907c… leo 2279 chat_model=None,
ecf907c… leo 2280 source="local",
ecf907c… leo 2281 folder_id=None,
ecf907c… leo 2282 folder_path=None,
ecf907c… leo 2283 recursive=True,
ecf907c… leo 2284 )
ecf907c… leo 2285
ecf907c… leo 2286 elif choice == 3:
ecf907c… leo 2287 ctx.invoke(list_models)
ecf907c… leo 2288
ecf907c… leo 2289 elif choice == 4:
ecf907c… leo 2290 service = click.prompt(
ecf907c… leo 2291 " Cloud service",
ecf907c… leo 2292 type=click.Choice(["google", "dropbox"]),
ecf907c… leo 2293 )
ecf907c… leo 2294 ctx.invoke(auth, service=service)
ecf907c… leo 2295
ecf907c… leo 2296 elif choice == 5:
ecf907c… leo 2297 cache_dir = click.prompt(" Cache directory path", type=click.Path())
ecf907c… leo 2298 clear_all = click.confirm(" Clear all entries?", default=True)
ecf907c… leo 2299 ctx.invoke(
ecf907c… leo 2300 clear_cache,
ecf907c… leo 2301 cache_dir=cache_dir,
ecf907c… leo 2302 older_than=None,
ecf907c… leo 2303 clear_all=clear_all,
ecf907c… leo 2304 )
ecf907c… leo 2305
ecf907c… leo 2306 elif choice == 6:
ecf907c… leo 2307 click.echo()
ecf907c… leo 2308 click.echo(ctx.get_help())
b363c5b… noreply 2309
b363c5b… noreply 2310 elif choice == 7:
b363c5b… noreply 2311 ctx.invoke(
b363c5b… noreply 2312 query,
b363c5b… noreply 2313 question=None,
b363c5b… noreply 2314 db_path=None,
b363c5b… noreply 2315 mode="auto",
b363c5b… noreply 2316 output_format="text",
b363c5b… noreply 2317 interactive=True,
b363c5b… noreply 2318 provider="auto",
b363c5b… noreply 2319 chat_model=None,
b363c5b… noreply 2320 )
09a0b7a… leo 2321
09a0b7a… leo 2322
a94205b… leo 2323 def main():
a94205b… leo 2324 """Entry point for command-line usage."""
a94205b… leo 2325 cli(obj={})
a94205b… leo 2326
09a0b7a… leo 2327
09a0b7a… leo 2328 if __name__ == "__main__":
a94205b… leo 2329 main()

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button