Skip to Content

SAM Insights Architecture v2 - eLearning Wrapper

SAM Insights Architecture v2 - eLearning Wrapper

Created: 2025-01-02
Status: Ready for Implementation
Module: ai_sam_documentation
Approach: Wrapper around website_slides (eLearning)


Executive Summary

Leverage Odoo's eLearning module (website_slides) for all UI/UX, and populate it from local .md files on module upgrade. Add a thin redirect layer for stable shareable URLs.

Key Benefits:
- Get eLearning's battle-tested UI for FREE (sidebar, fullscreen, search, responsive)
- Training mode gives hierarchical collapsible sidebar
- ~200 lines of custom code instead of 1000+
- Stable /sam_insights/ URLs that never change


Architecture Overview

┌─────────────────────────────────────────────────────────────────────────┐
│                         URL LAYERS                                       │
│                                                                          │
│  STABLE (Share with AI)          eLEARNING (Actual Content)             │
│  ━━━━━━━━━━━━━━━━━━━━━━          ━━━━━━━━━━━━━━━━━━━━━━━━━              │
│  /sam_insights/<slug>    ──────→  /slides/<channel>/<slide>             │
│                          redirect                                        │
│                                                                          │
│  Examples:                                                               │
│  /sam_insights/cto-capabilities  → /slides/sam-skills/cto-capabilities  │
│  /sam_insights/ai-brain-schema   → /slides/modules/ai-brain-schema      │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                         DATA FLOW                                        │
│                                                                          │
│  docs/*.md files                                                         │
│       │                                                                  │
│       ▼                                                                  │
│  build_courses.py (runs on -u upgrade)                                  │
│       │                                                                  │
│       ▼                                                                  │
│  slide.channel (Courses) + slide.slide (Content)                        │
│       │                                                                  │
│       ▼                                                                  │
│  eLearning Templates (website_slides)                                   │
│       │                                                                  │
│       ▼                                                                  │
│  /slides/... URLs (with training mode sidebar)                          │
└─────────────────────────────────────────────────────────────────────────┘

Folder Structure

ai_sam_documentation/
│
├── __manifest__.py
├── __init__.py
│
├── docs/                                ← KNOWLEDGE SOURCE
│   ├── _url_registry.json               ← Stable slug mappings
│   ├── _course_config.json              ← Course metadata
│   │
│   ├── 00_sam_skills/                   → Creates Course "SAM Skills"
│   │   ├── cto/                         → Section "CTO"
│   │   │   ├── capabilities.md          → Slide (article)
│   │   │   └── workflow.md              → Slide (article)
│   │   └── developer/                   → Section "Developer"
│   │       └── patterns.md              → Slide (article)
│   │
│   ├── 01_modules/                      → Creates Course "Modules"
│   │   ├── ai_brain/                    → Section "AI Brain"
│   │   │   ├── description.md
│   │   │   └── schema.md
│   │   └── ai_sam/                      → Section "AI SAM"
│   │
│   ├── 02_data_flows/                   → Creates Course "Data Flows"
│   │   ├── apis/
│   │   ├── chat/
│   │   ├── node_creation/
│   │   └── system_prompt_builder/
│   │
│   ├── 03_platform_skins/               → Creates Course "Platform Skins"
│   │
│   ├── 04_architecture/                 → Creates Course "Architecture"
│   │
│   └── 05_vision/                       → Creates Course "Vision"
│
├── controllers/
│   ├── __init__.py
│   └── redirect_controller.py           ← /sam_insights/ redirect
│
├── scripts/
│   ├── __init__.py
│   └── build_courses.py                 ← Main build script
│
├── data/
│   └── website_menu.xml                 ← Optional menu items
│
├── security/
│   └── ir.model.access.csv              ← Minimal (if needed)
│
└── static/
    └── description/
        └── icon.png

Phase 1: Foundation

Task 1.1: Update Manifest

File: __manifest__.py

{
    'name': 'SAM AI Documentation & Insights',
    'version': '18.0.1.0.0',
    'author': 'Anthony Gardiner - Odoo Consulting & Claude AI',
    'maintainer': 'Anthony Gardiner <[email protected]>',
    'website': 'https://sme.ec',
    'category': 'Website/eLearning',
    'license': 'LGPL-3',
    'summary': 'SAM AI Knowledge System - File-based eLearning content',
    'description': """
SAM AI Documentation & Insights
================================

Knowledge publishing system built on Odoo eLearning.

Features:
- Markdown files auto-convert to eLearning content
- Hierarchical sidebar navigation (training mode)
- Stable /sam_insights/ URLs for AI sessions
- Sync on module upgrade

Source: docs/*.md files
URLs: /sam_insights/<slug> (stable) → /slides/... (eLearning)
    """,
    'depends': [
        'website_slides',  # eLearning - provides all UI
    ],
    'data': [
        # 'security/ir.model.access.csv',
        # 'data/website_menu.xml',
    ],
    'post_init_hook': 'post_init_hook',
    'post_load': None,
    'images': ['static/description/icon.png'],
    'installable': True,
    'application': False,
    'auto_install': False,
}

Task 1.2: Create Root init.py

File: __init__.py

from . import controllers
from .scripts.build_courses import build_courses


def post_init_hook(env):
    """Build courses from docs/ folder after install/upgrade."""
    build_courses(env)

Task 1.3: Create URL Registry

File: docs/_url_registry.json

{
  "_meta": {
    "description": "Maps stable /sam_insights/ slugs to eLearning locations",
    "note": "When content moves, update target_channel/target_slide here. Slug never changes.",
    "updated": "2025-01-02"
  },
  "redirects": {
    "cto-capabilities": {
      "target_channel": "00-sam-skills",
      "target_slide": "cto-capabilities",
      "title": "CTO Capabilities",
      "created": "2025-01-02"
    },
    "ai-brain-overview": {
      "target_channel": "01-modules",
      "target_slide": "ai-brain-description",
      "title": "AI Brain Overview",
      "created": "2025-01-02"
    }
  }
}

Task 1.4: Create Course Config

File: docs/_course_config.json

{
  "_meta": {
    "description": "Configuration for auto-generated courses",
    "updated": "2025-01-02"
  },
  "courses": {
    "00_sam_skills": {
      "name": "SAM Skills",
      "description": "Agent capabilities and skills documentation",
      "channel_type": "training",
      "sequence": 0,
      "visibility": "members",
      "enroll": "invite"
    },
    "01_modules": {
      "name": "Modules",
      "description": "Per-module reference documentation",
      "channel_type": "training",
      "sequence": 10,
      "visibility": "members",
      "enroll": "invite"
    },
    "02_data_flows": {
      "name": "Data Flows",
      "description": "How data moves through the system",
      "channel_type": "training",
      "sequence": 20,
      "visibility": "members",
      "enroll": "invite"
    },
    "03_platform_skins": {
      "name": "Platform Skins",
      "description": "How platform skins work",
      "channel_type": "training",
      "sequence": 30,
      "visibility": "members",
      "enroll": "invite"
    },
    "04_architecture": {
      "name": "Architecture",
      "description": "High-level patterns and decisions",
      "channel_type": "training",
      "sequence": 40,
      "visibility": "members",
      "enroll": "invite"
    },
    "05_vision": {
      "name": "Vision",
      "description": "Strategic direction",
      "channel_type": "training",
      "sequence": 50,
      "visibility": "members",
      "enroll": "invite"
    }
  },
  "defaults": {
    "channel_type": "training",
    "visibility": "members",
    "enroll": "invite",
    "allow_comment": false
  }
}

Phase 2: Build Script

Task 2.1: Create Build Script

File: scripts/__init__.py

from . import build_courses

File: scripts/build_courses.py

"""
Build eLearning courses from docs/ folder structure.

Folder structure:
  docs/
    00_sam_skills/        → slide.channel (Course)
      cto/                → slide.slide (is_category=True, Section)
        capabilities.md   → slide.slide (article content)

Runs on module install/upgrade via post_init_hook.
"""

import json
import logging
import re
from pathlib import Path
from datetime import datetime

_logger = logging.getLogger(__name__)

# Optional markdown - fallback to basic if not available
try:
    import markdown
    MARKDOWN_AVAILABLE = True
except ImportError:
    MARKDOWN_AVAILABLE = False
    _logger.warning("markdown library not installed. Using basic conversion.")


def get_module_path():
    """Get the ai_sam_documentation module path."""
    from odoo.modules.module import get_module_path as odoo_get_module_path
    return Path(odoo_get_module_path('ai_sam_documentation'))


def load_json_config(filename):
    """Load a JSON config file from docs/."""
    config_path = get_module_path() / 'docs' / filename
    if config_path.exists():
        with open(config_path, 'r', encoding='utf-8') as f:
            return json.load(f)
    return {}


def slugify(text):
    """Convert text to URL-safe slug."""
    text = text.lower().strip()
    text = re.sub(r'[^\w\s-]', '', text)
    text = re.sub(r'[-\s]+', '-', text)
    return text


def convert_md_to_html(content):
    """Convert markdown content to HTML."""
    if MARKDOWN_AVAILABLE:
        return markdown.markdown(
            content,
            extensions=['tables', 'fenced_code', 'toc', 'meta', 'nl2br']
        )
    else:
        # Basic conversion
        html = content
        html = re.sub(r'^### (.+)$', r'<h3>\1</h3>', html, flags=re.MULTILINE)
        html = re.sub(r'^## (.+)$', r'<h2>\1</h2>', html, flags=re.MULTILINE)
        html = re.sub(r'^# (.+)$', r'<h1>\1</h1>', html, flags=re.MULTILINE)
        html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
        html = re.sub(r'\*(.+?)\*', r'<em>\1</em>', html)
        html = re.sub(r'```(\w*)\n(.*?)```', r'<pre><code class="language-\1">\2</code></pre>', html, flags=re.DOTALL)
        html = re.sub(r'`(.+?)`', r'<code>\1</code>', html)
        html = html.replace('\n\n', '</p><p>')
        return f'<div class="markdown-content"><p>{html}</p></div>'


def extract_title_from_md(content, filename):
    """Extract title from markdown content or filename."""
    # Try to find # Title at start
    match = re.match(r'^#\s+(.+)$', content.strip(), re.MULTILINE)
    if match:
        return match.group(1).strip()
    # Fall back to filename
    return filename.replace('.md', '').replace('_', ' ').replace('-', ' ').title()


def get_or_create_channel(env, folder_name, config):
    """Get or create a slide.channel for the given folder."""
    Channel = env['slide.channel']

    # Get config for this course
    course_config = config.get('courses', {}).get(folder_name, {})
    defaults = config.get('defaults', {})

    # Generate slug from folder name (e.g., "00_sam_skills" → "00-sam-skills")
    channel_slug = folder_name.replace('_', '-')

    # Check if channel exists (by website_slug or name pattern)
    # website_slug might not exist, so we search by name pattern
    display_name = course_config.get('name', folder_name.replace('_', ' ').title())

    existing = Channel.search([
        '|',
        ('name', '=', display_name),
        ('name', 'ilike', f'%{display_name}%')
    ], limit=1)

    if existing:
        _logger.info(f"Found existing channel: {existing.name}")
        return existing

    # Create new channel
    vals = {
        'name': display_name,
        'description': course_config.get('description', ''),
        'channel_type': course_config.get('channel_type', defaults.get('channel_type', 'training')),
        'visibility': course_config.get('visibility', defaults.get('visibility', 'members')),
        'enroll': course_config.get('enroll', defaults.get('enroll', 'invite')),
        'allow_comment': course_config.get('allow_comment', defaults.get('allow_comment', False)),
        'sequence': course_config.get('sequence', 50),
        'is_published': False,  # Start unpublished
    }

    channel = Channel.create(vals)
    _logger.info(f"Created channel: {channel.name}")
    return channel


def get_or_create_section(env, channel, section_name, sequence):
    """Get or create a section (slide with is_category=True)."""
    Slide = env['slide.slide']

    display_name = section_name.replace('_', ' ').replace('-', ' ').title()

    existing = Slide.search([
        ('channel_id', '=', channel.id),
        ('is_category', '=', True),
        ('name', '=', display_name)
    ], limit=1)

    if existing:
        return existing

    section = Slide.create({
        'name': display_name,
        'channel_id': channel.id,
        'is_category': True,
        'is_published': False,
        'sequence': sequence,
    })
    _logger.info(f"Created section: {section.name} in {channel.name}")
    return section


def get_or_create_slide(env, channel, section, md_file, sequence):
    """Get or create a slide from markdown file."""
    Slide = env['slide.slide']

    # Read markdown content
    content_md = md_file.read_text(encoding='utf-8')
    title = extract_title_from_md(content_md, md_file.name)
    content_html = convert_md_to_html(content_md)

    # Generate slug
    slug = slugify(md_file.stem)

    # Check if slide exists
    existing = Slide.search([
        ('channel_id', '=', channel.id),
        ('name', '=', title)
    ], limit=1)

    if existing:
        # Update content
        existing.write({
            'html_content': content_html,
        })
        _logger.info(f"Updated slide: {existing.name}")
        return existing

    # Create new slide
    slide = Slide.create({
        'name': title,
        'channel_id': channel.id,
        'slide_category': 'article',  # Use article type for .md content
        'html_content': content_html,
        'is_published': False,
        'is_preview': False,
        'sequence': sequence,
    })
    _logger.info(f"Created slide: {slide.name} in {channel.name}")
    return slide


def build_courses(env):
    """Main entry point - build all courses from docs/ folder."""
    _logger.info("=" * 60)
    _logger.info("Building SAM AI Courses from docs/ folder...")
    _logger.info("=" * 60)

    module_path = get_module_path()
    docs_path = module_path / 'docs'

    if not docs_path.exists():
        _logger.warning(f"docs/ folder not found at {docs_path}")
        return

    # Load configuration
    course_config = load_json_config('_course_config.json')

    # Track statistics
    stats = {
        'channels': 0,
        'sections': 0,
        'slides': 0,
    }

    # Process each numbered folder (00_*, 01_*, etc.)
    course_folders = sorted([
        f for f in docs_path.iterdir()
        if f.is_dir() and not f.name.startswith('_') and re.match(r'^\d{2}_', f.name)
    ])

    for course_folder in course_folders:
        _logger.info(f"\nProcessing course folder: {course_folder.name}")

        # Create/get channel
        channel = get_or_create_channel(env, course_folder.name, course_config)
        stats['channels'] += 1

        # Process subfolders as sections
        section_sequence = 0
        for section_folder in sorted(course_folder.iterdir()):
            if not section_folder.is_dir():
                continue
            if section_folder.name.startswith('_'):
                continue

            section_sequence += 10
            section = get_or_create_section(env, channel, section_folder.name, section_sequence)
            stats['sections'] += 1

            # Process .md files in section
            slide_sequence = 0
            for md_file in sorted(section_folder.glob('*.md')):
                slide_sequence += 10
                slide = get_or_create_slide(env, channel, section, md_file, slide_sequence)
                stats['slides'] += 1

        # Also process .md files directly in course folder (no section)
        slide_sequence = 1000  # High sequence to put at end
        for md_file in sorted(course_folder.glob('*.md')):
            slide_sequence += 10
            slide = get_or_create_slide(env, channel, None, md_file, slide_sequence)
            stats['slides'] += 1

    _logger.info("=" * 60)
    _logger.info(f"Build complete!")
    _logger.info(f"  Channels: {stats['channels']}")
    _logger.info(f"  Sections: {stats['sections']}")
    _logger.info(f"  Slides:   {stats['slides']}")
    _logger.info("=" * 60)

    # Commit to ensure data is saved
    env.cr.commit()

Phase 3: Redirect Controller

Task 3.1: Create Redirect Controller

File: controllers/__init__.py

from . import redirect_controller

File: controllers/redirect_controller.py

"""
Redirect controller for stable /sam_insights/ URLs.

Maps stable slugs to actual eLearning URLs.
Allows content to be reorganized without breaking shared links.
"""

import json
import logging
from pathlib import Path

from odoo import http
from odoo.http import request

_logger = logging.getLogger(__name__)


class SamInsightsRedirect(http.Controller):

    def _load_registry(self):
        """Load URL registry from JSON file."""
        from odoo.modules.module import get_module_path
        module_path = Path(get_module_path('ai_sam_documentation'))
        registry_path = module_path / 'docs' / '_url_registry.json'

        if registry_path.exists():
            with open(registry_path, 'r', encoding='utf-8') as f:
                return json.load(f)
        return {'redirects': {}}

    @http.route('/sam_insights', type='http', auth='public', website=True)
    def insights_index(self, **kwargs):
        """Index page - redirect to eLearning courses list or custom page."""
        return request.redirect('/slides')

    @http.route('/sam_insights/<string:slug>', type='http', auth='public', website=True)
    def insight_redirect(self, slug, **kwargs):
        """Redirect stable slug to actual eLearning URL."""
        registry = self._load_registry()
        redirects = registry.get('redirects', {})

        if slug in redirects:
            entry = redirects[slug]
            target_channel = entry.get('target_channel', '')
            target_slide = entry.get('target_slide', '')

            # Build eLearning URL
            if target_slide:
                target_url = f'/slides/slide/{target_slide}'
            elif target_channel:
                target_url = f'/slides/{target_channel}'
            else:
                target_url = '/slides'

            _logger.debug(f"Redirecting /sam_insights/{slug} → {target_url}")
            return request.redirect(target_url)

        # Slug not found - try to find by searching slides
        Slide = request.env['slide.slide'].sudo()
        slide = Slide.search([
            ('name', 'ilike', slug.replace('-', ' ')),
            ('is_category', '=', False)
        ], limit=1)

        if slide:
            return request.redirect(f'/slides/slide/{slide.id}')

        # Not found
        _logger.warning(f"SAM Insights slug not found: {slug}")
        return request.redirect('/slides')

    @http.route('/sam_insights/course/<string:course_slug>', type='http', auth='public', website=True)
    def course_redirect(self, course_slug, **kwargs):
        """Redirect to a course/channel."""
        Channel = request.env['slide.channel'].sudo()

        # Try to find channel by slug pattern
        search_name = course_slug.replace('-', ' ').replace('_', ' ')
        channel = Channel.search([
            ('name', 'ilike', f'%{search_name}%')
        ], limit=1)

        if channel:
            return request.redirect(f'/slides/{channel.id}')

        return request.redirect('/slides')

Phase 4: Initial Content Setup

Task 4.1: Create Folder Structure

cd D:\SAMAI-18-SaaS\github-repos\05-samai-core\ai_sam_documentation

# Create docs structure
mkdir -p docs/00_sam_skills/cto
mkdir -p docs/00_sam_skills/developer
mkdir -p docs/00_sam_skills/architect
mkdir -p docs/01_modules/ai_brain
mkdir -p docs/01_modules/ai_sam
mkdir -p docs/01_modules/ai_sam_workflows
mkdir -p docs/02_data_flows/apis
mkdir -p docs/02_data_flows/chat
mkdir -p docs/02_data_flows/node_creation
mkdir -p docs/02_data_flows/system_prompt_builder
mkdir -p docs/03_platform_skins
mkdir -p docs/04_architecture
mkdir -p docs/05_vision

# Create controller folder
mkdir -p controllers

# Create scripts folder
mkdir -p scripts

Task 4.2: Create Sample Content

File: docs/00_sam_skills/cto/capabilities.md

# CTO Capabilities

The CTO agent provides strategic technical leadership.

## Core Capabilities

- Infrastructure strategy and planning
- Technology stack decisions
- Scalability architecture
- Security oversight

## When to Use

Use the CTO agent when you need:
- High-level architectural decisions
- Technology evaluation
- Strategic technical planning

## Invocation

/cto


File: docs/01_modules/ai_brain/description.md

# AI Brain Module

The foundation data layer of SAM AI.

## Purpose

AI Brain (`ai_brain`) is the core data layer where ALL models live.
Platform skins can be uninstalled without losing data.

## Key Models

- `canvas` - Workflow canvas
- `nodes` - Canvas nodes
- `ai.conversation` - Chat conversations
- `ai.message` - Chat messages

## Philosophy

"The Brain" - permanent data storage, never uninstall.

Phase 5: Testing & Validation

Task 5.1: Install and Test

# Upgrade module
./odoo-bin -u ai_sam_documentation -d your_database

# Check logs for build output
# Should see: "Building SAM AI Courses from docs/ folder..."

Task 5.2: Validation Checklist

  • [ ] Module installs without errors
  • [ ] Courses appear in eLearning (/slides)
  • [ ] Each numbered folder creates a Course
  • [ ] Subfolders create Sections (collapsible in sidebar)
  • [ ] .md files create Article slides
  • [ ] Content displays correctly (HTML from markdown)
  • [ ] /sam_insights/<slug> redirects work
  • [ ] Training mode sidebar shows hierarchy

Mapping Reference

Folder → eLearning

Folder Structure eLearning Equivalent
docs/00_sam_skills/ slide.channel (Course)
docs/00_sam_skills/cto/ slide.slide (is_category=True, Section)
docs/00_sam_skills/cto/capabilities.md slide.slide (article)

URL Mapping

Stable URL Redirects To
/sam_insights/ /slides
/sam_insights/cto-capabilities /slides/slide/<id>
/sam_insights/course/sam-skills /slides/<channel_id>

Future Enhancements (Not in Scope)

  • Auto-generate _url_registry.json from slides
  • Search across all insights
  • API endpoint for AI sessions to fetch content
  • Webhook to rebuild on git push
  • Custom eLearning template overrides (if needed)

Developer Handoff

When ready to implement, start a developer session with:

Implement the SAM Insights eLearning wrapper.

Plan: D:\SAMAI-18-SaaS\github-repos\05-samai-core\ai_sam_documentation\plans\2025-01-02_sam_insights_architecture_v2.md

Start with Phase 1 (manifest, __init__.py, config files).
Then Phase 2 (build script).
Then Phase 3 (redirect controller).
Then Phase 4 (create folder structure and sample content).

The module depends on website_slides (eLearning).
We are NOT creating custom models - we populate slide.channel and slide.slide.

End of Plan v2

Was this helpful?