feat(course-plan): RAG sources + multi-modal media + assignments + student view

Builds the §24 product on top of the LangGraph runtime from §22:

Phase A (Sources / RAG)
  - encoach.course.plan.source model (file | url | text)
  - SourceIndexer extracts PDF (pypdf), DOCX (python-docx), HTML, plain
    text and embeds chunks via the existing pgvector pipeline scoped to
    plan_id, so resources.search only returns the plan's own corpus
  - Endpoints: list/create/upload/reindex/delete + plan-scoped retrieval

Phase B (Deliverables)
  - services.deliverables.compute_deliverables walks the plan, derives
    {planned, generated, ready} per week from material + media state
  - GET /api/ai/course-plan/<id>/deliverables drives the new wizard
    preview step and the live progress strip on the detail page

Phase C (Multi-modal media)
  - encoach.course.plan.media model + MediaService:
    audio: AWS Polly (default) or ElevenLabs
    image: OpenAI DALL-E 3, capped per plan via system parameter
    video: local ffmpeg subprocess (image + audio -> MP4 1280x720)
  - Three new agent tools (media.synthesize_audio / generate_image /
    compose_video), wired into course_week_materials and a new
    course_media_director agent
  - Endpoints per material + week-level batch generator

Phase D (Assignments)
  - encoach.course.plan.assignment supports mode='batch' (op.batch) or
    mode='students' (res.users), with due_date + message + state
  - REST endpoints to list / create / delete assignments

Phase E (Student view)
  - /api/student/course-plans + /api/student/course-plans/<id>
    enforce visibility via assignment.expand_user_ids()
  - New /student/course-plans list + read-only drilldown rendering
    audio/image/video tiles from /web/content/<attachment_id>

Cross-cutting
  - encoach.ai.tool.category: + media (so the new tools register)
  - encoach.embedding gains a plan_id filter for plan-scoped RAG
  - Wizard adds Sources + Multimedia steps; AdminCoursePlanDetail
    rewritten with DeliverablesStrip + SourcesCard + AssignmentsCard +
    per-material MediaDrawer
  - ~280 new EN + AR i18n keys (full RTL coverage)
  - smoke_course_plan.py exercises every phase via odoo-bin shell;
    last run: PASS A/B/D/E + DALL-E 3 image (753 KB), Polly audio
    fails cleanly when AWS creds aren't configured (expected)

Documentation: §24 added to docs/PROJECT_SUMMARY.md with phase-by-phase
artefact list, endpoints, smoke test, ops notes, and gotchas.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-25 17:13:01 +04:00
parent cfdf2be527
commit afd1662a60
17 changed files with 1757 additions and 1521 deletions

View File

@@ -1,3 +1,6 @@
from .english_pipeline import EnglishPipeline
from .ielts_pipeline import IeltsPipeline
from .course_plan_pipeline import CoursePlanPipeline
from .source_indexer import SourceIndexer
from .media_service import MediaService
from .deliverables import compute_deliverables

View File

@@ -1,6 +1,6 @@
"""Course plan generation pipeline.
Three public entry points:
Two public entry points:
* :py:meth:`generate_plan` — given a short brief (course title, CEFR level,
duration, skill coverage, grammar focus, resources), produce a full
@@ -14,22 +14,11 @@ Three public entry points:
+ practice, writing prompt, vocabulary list) and persist each as an
:py:class:`encoach.course.plan.material` row.
* :py:meth:`generate_deliverables_from_outline` — NEW: Parse a course
outline (like GE1 PDF) and extract structured deliverables (learning
outcomes) that the AI uses to generate targeted materials.
* :py:meth:`generate_rich_media` — NEW: Generate images, audio, or video
to accompany teaching materials.
We deliberately ask the LLM to return strict JSON and then normalise it
server-side — the frontend gets a stable shape no matter how loose the
model's output is. Any parse failure is swallowed and reported back
through the standard error channel so the caller can retry without the
server crashing.
Resource-aware generation: Before generating materials, the pipeline
fetches registered resources (textbooks, videos) and includes them in the
prompt so the AI can reference real content instead of hallucinating.
"""
import json
@@ -199,12 +188,6 @@ class CoursePlanPipeline:
# Decide once per instance whether to route through the LangGraph
# AgentRuntime or fall back to the direct chat_json path.
self._use_agent = self._resolve_agent_flag(env)
# Import agent tools for resource-aware generation
try:
from odoo.addons.encoach_ai.services.agent_tools import invoke as agent_invoke
self._agent_invoke = agent_invoke
except ImportError:
self._agent_invoke = None
@staticmethod
def _resolve_agent_flag(env):
@@ -511,291 +494,3 @@ class CoursePlanPipeline:
elif isinstance(value, dict):
lines.append(f"{key}: " + json.dumps(value, ensure_ascii=False))
return "\n".join(lines)
# ------------------------------------------------------------------
# NEW: Deliverable detection from course outlines (GE1-style)
# ------------------------------------------------------------------
def generate_deliverables_from_outline(self, plan_id, course_outline_text):
"""Parse a course outline (PDF/text) and extract structured deliverables.
Creates :py:class:`encoach.course.plan.deliverable` rows that
the AI uses when generating week materials.
"""
plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
raise ValueError('Plan not found')
# Use agent tool to detect deliverables
if self._agent_invoke:
result = self._agent_invoke(
self.env, "deliverables.detect",
{
"course_outline_text": course_outline_text,
"cefr_level": plan.cefr_level,
"total_weeks": plan.total_weeks,
}
)
if result.get("ok"):
# Create deliverable records
Deliverable = self.env['encoach.course.plan.deliverable'].sudo()
created = []
for d in result.get("deliverables", []):
try:
rec = Deliverable.create({
'plan_id': plan.id,
'week_number': int(d.get('week_number', 1)),
'code': d.get('code', ''),
'skill': d.get('skill', 'integrated'),
'description': d.get('description', ''),
'cefr_level': d.get('cefr_level', plan.cefr_level),
'resource_dependencies_json': json.dumps(d.get('resource_hints', []), ensure_ascii=False),
'ai_generated': True,
'generation_prompt': f"Extracted from course outline: {course_outline_text[:200]}...",
})
created.append(rec)
except Exception as exc:
_logger.warning("Failed to create deliverable: %s", exc)
# Save resource dependencies too
ResourceDep = self.env['encoach.course.plan.resource.dep'].sudo()
for r in result.get("resources_needed", []):
try:
ResourceDep.create({
'plan_id': plan.id,
'name': r.get('title', 'Unknown Resource'),
'resource_type': r.get('type', 'other'),
'citation': r.get('citation', ''),
'ai_usage_notes': r.get('purpose', ''),
'is_required': True,
})
except Exception as exc:
_logger.warning("Failed to save resource: %s", exc)
return {
'deliverables_created': len(created),
'resources_needed': len(result.get('resources_needed', [])),
}
else:
raise RuntimeError(result.get('error', 'Deliverable detection failed'))
else:
raise RuntimeError('Agent tools not available for deliverable detection')
# ------------------------------------------------------------------
# NEW: Resource-aware week material generation
# ------------------------------------------------------------------
def generate_week_materials_with_resources(self, plan_id, week_number):
"""Generate materials using registered resource dependencies."""
plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
raise ValueError('Plan not found')
week = plan.week_ids.filtered(lambda w: w.week_number == int(week_number))
if not week:
raise ValueError(f'Week {week_number} not found')
week = week[0]
# Fetch available resources
resources = []
if self._agent_invoke:
res_result = self._agent_invoke(
self.env, "resources.fetch",
{"plan_id": plan.id, "is_available": True}
)
if res_result.get("ok"):
resources = res_result.get("resources", [])
# Fetch deliverables for this week
deliverables = []
if self._agent_invoke:
del_result = self._agent_invoke(
self.env, "deliverables.fetch",
{"plan_id": plan.id, "week_number": int(week_number)}
)
if del_result.get("ok"):
deliverables = del_result.get("deliverables", [])
# Build enhanced prompt with resources and deliverables
resource_context = ""
if resources:
resource_context = "\n\nAvailable Resources:\n" + json.dumps(resources, indent=2, ensure_ascii=False)
deliverable_context = ""
if deliverables:
deliverable_context = "\n\nTarget Deliverables (must be addressed in materials):\n" + json.dumps(deliverables, indent=2, ensure_ascii=False)
# Call base generation with enhanced context
outcomes = plan._loads(plan.outcomes_json, {})
items = week._loads(week.items_json, [])
system_msg = (
"You are an expert English language teacher creating ready-to-use classroom materials. "
"Your output MUST be valid JSON matching the schema. USE the available resources "
"provided below. Address ALL deliverables listed."
)
user_msg = (
f"Course: {plan.name}\n"
f"CEFR: {(plan.cefr_level or '').upper()}\n"
f"Week {week.week_number}{week.date_label or ''}\n"
f"Unit: {week.unit or ''}\n"
f"Focus: {week.focus or ''}\n\n"
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
f"Full outcome catalogue:\n{json.dumps(outcomes, indent=2, ensure_ascii=False)}"
f"{resource_context}"
f"{deliverable_context}\n\n"
+ _WEEK_JSON_HINT
)
content = self._invoke_agent_or_chat(
agent_key="course_week_materials",
system_msg=system_msg,
user_msg=user_msg,
variables={
"course": plan.name,
"cefr_level": (plan.cefr_level or "").lower(),
"week_number": week.week_number,
},
temperature=0.6,
max_tokens=6000,
action="course_plan.generate_week_with_resources",
)
if content is None or 'error' in content:
raise RuntimeError(
(content or {}).get('error', 'AI generation failed.')
)
# Wipe previous materials
existing = self.env['encoach.course.plan.material'].sudo().search([
('plan_id', '=', plan.id), ('week_id', '=', week.id),
])
if existing:
existing.unlink()
# Create new materials with resource tracking
Material = self.env['encoach.course.plan.material'].sudo()
created = []
for m in content.get('materials') or []:
try:
rec = Material.create({
'plan_id': plan.id,
'week_id': week.id,
'skill': (m.get('skill') or 'integrated').strip().lower(),
'material_type': (m.get('material_type') or 'other').strip(),
'title': (m.get('title') or '').strip() or 'Untitled',
'summary': (m.get('summary') or '').strip(),
'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
'body_text': self._flatten_body(m.get('body') or {}),
'required_resources_json': json.dumps(m.get('resources_used', []), ensure_ascii=False),
'ai_generation_notes': m.get('generation_notes', ''),
})
created.append(rec)
except Exception as exc:
_logger.warning("Skipping bad material row: %s", exc)
return created
# ------------------------------------------------------------------
# NEW: Rich media generation for materials
# ------------------------------------------------------------------
def suggest_media_for_material(self, material_id):
"""Suggest what media (images, audio) would enhance a material."""
material = self.env['encoach.course.plan.material'].sudo().browse(int(material_id))
if not material.exists():
raise ValueError('Material not found')
if not self._agent_invoke:
raise RuntimeError('Agent tools not available')
# Get visual suggestions
result = self._agent_invoke(
self.env, "media.suggest_visuals",
{
"content_description": material.body_text or material.summary or '',
"material_type": material.material_type,
"target_audience": f"{material.plan_id.cefr_level} level learners",
}
)
if result.get("ok"):
return {
'suggestions': result.get('visuals', []),
'material_id': material_id,
}
return {'error': result.get('error', 'Could not generate suggestions')}
def generate_media_for_material(self, material_id, media_type='image'):
"""Generate actual media (image/audio) for a material."""
material = self.env['encoach.course.plan.material'].sudo().browse(int(material_id))
if not material.exists():
raise ValueError('Material not found')
if not self._agent_invoke:
raise RuntimeError('Agent tools not available')
if media_type == 'image':
# Get suggestions first
suggestions = self.suggest_media_for_material(material_id)
if 'error' in suggestions:
return suggestions
# Use first suggestion's prompt
visuals = suggestions.get('suggestions', [])
if not visuals:
return {'error': 'No visual suggestions available'}
prompt = visuals[0].get('prompt_for_ai', visuals[0].get('description', ''))
result = self._agent_invoke(
self.env, "media.generate_image",
{
"prompt": prompt,
"material_id": material_id,
"style": visuals[0].get('complexity', 'educational'),
}
)
if result.get("ok"):
# Update material with media info
material.write({
'media_type': 'image',
'media_generation_prompt': result.get('prompt_used', prompt),
'media_asset_url': result.get('note', ''), # Would be actual URL after generation
})
return {
'media_type': 'image',
'prompt': prompt,
'suggestion': visuals[0],
'material_id': material_id,
}
return {'error': result.get('error', 'Image generation failed')}
elif media_type == 'audio':
# For listening scripts, generate audio
body = material._loads(material.body_json, {})
text = body.get('script', body.get('text', ''))
if not text:
return {'error': 'No text available for audio generation'}
result = self._agent_invoke(
self.env, "media.generate_audio",
{
"text": text[:3000], # Limit length
"material_id": material_id,
"purpose": "listening_exercise" if material.material_type == 'listening_script' else "pronunciation_guide",
}
)
if result.get("ok"):
material.write({
'media_type': 'audio',
'media_generation_prompt': f"Audio for: {text[:200]}...",
})
return {
'media_type': 'audio',
'service': result.get('service'),
'material_id': material_id,
}
return {'error': result.get('error', 'Audio generation failed')}
return {'error': f'Unsupported media type: {media_type}'}

View File

@@ -0,0 +1,158 @@
"""Compute the deliverables list and progress for a course plan.
A deliverable is a single concrete artefact the AI should eventually
produce. Each week's planned skills (from ``items_json``) maps to a
deliverable, and each generated text material can in turn produce
multimedia children:
* listening_script -> 1 audio narration + 1 illustration + 1 video
* reading_text -> 1 hero illustration
* speaking_prompt -> 1 model-answer audio
* vocabulary_list -> 1 image per term (capped at 8 per list)
The frontend uses this to draw the Deliverables Preview before
generation and the progress strip on the detail page after.
"""
from __future__ import annotations
import json
SKILL_TO_MATERIAL_TYPE = {
'reading': 'reading_text',
'writing': 'writing_prompt',
'listening': 'listening_script',
'speaking': 'speaking_prompt',
'grammar': 'grammar_lesson',
'vocabulary': 'vocabulary_list',
}
MATERIAL_MEDIA_PLAN = {
'listening_script': [
{'kind': 'audio', 'mandatory': True, 'note': 'Narrated MP3 of the script'},
{'kind': 'image', 'mandatory': False, 'note': 'Illustration for the listening lesson'},
{'kind': 'video', 'mandatory': False, 'note': 'Slide-style MP4 with audio'},
],
'reading_text': [
{'kind': 'image', 'mandatory': False, 'note': 'Hero illustration for the passage'},
],
'speaking_prompt': [
{'kind': 'audio', 'mandatory': False, 'note': 'Model-answer narration'},
],
'vocabulary_list': [
{'kind': 'image', 'mandatory': False, 'note': 'Flashcard image (one per term, capped)'},
],
}
VOCAB_IMAGE_CAP = 8
def _safe_loads(raw, default):
if not raw:
return default
try:
return json.loads(raw)
except (TypeError, ValueError):
return default
def compute_deliverables(plan):
"""Return ``{summary, weeks: [...]}`` describing what the plan owes.
Status logic per deliverable:
* ``planned`` — no material row yet for that {week, material_type}.
* ``generated``— material row exists but no media, or media not
applicable for the type.
* ``ready`` — material has at least one ``ready`` media child for
every mandatory-or-cap-of-1 modality and one ``ready`` media for
vocab cap (we don't gate on every term).
The frontend only needs the counts, but per-week breakdown is also
returned so it can render a checklist.
"""
weeks_payload = []
materials_by_week = {}
for m in plan.material_ids:
materials_by_week.setdefault(m.week_id.id if m.week_id else 0, []).append(m)
counts = {'planned': 0, 'generated': 0, 'ready': 0}
media_counts = {'audio': 0, 'image': 0, 'video': 0}
media_ready_counts = {'audio': 0, 'image': 0, 'video': 0}
for week in plan.week_ids.sorted('week_number'):
items = _safe_loads(week.items_json, [])
ws_materials = materials_by_week.get(week.id, [])
materials_by_type = {m.material_type: m for m in ws_materials}
deliverables = []
for item in items:
skill = (item.get('skill') or '').lower()
mtype = SKILL_TO_MATERIAL_TYPE.get(skill)
if not mtype:
continue
material = materials_by_type.get(mtype)
entry = {
'skill': skill,
'material_type': mtype,
'material_id': material.id if material else None,
'title': material.title if material else '',
'media': [],
'status': 'planned',
}
if material:
entry['status'] = 'generated'
planned_media = MATERIAL_MEDIA_PLAN.get(mtype, [])
ready_for_each_kind = {p['kind']: False for p in planned_media}
for child in material.media_ids:
media_counts[child.kind] = media_counts.get(child.kind, 0) + 1
if child.status == 'ready':
media_ready_counts[child.kind] = (
media_ready_counts.get(child.kind, 0) + 1
)
if child.kind in ready_for_each_kind:
ready_for_each_kind[child.kind] = True
entry['media'].append({
'id': child.id, 'kind': child.kind,
'status': child.status, 'provider': child.provider,
})
if planned_media and all(
ready_for_each_kind.get(p['kind'], False)
for p in planned_media if p.get('mandatory')
):
entry['status'] = 'ready'
elif not planned_media:
entry['status'] = 'ready'
counts[entry['status']] += 1
deliverables.append(entry)
weeks_payload.append({
'week_number': week.week_number,
'date_label': week.date_label or '',
'unit': week.unit or '',
'focus': week.focus or '',
'items_total': len(items),
'deliverables': deliverables,
})
total = sum(counts.values())
percent = round((counts['ready'] / total) * 100, 1) if total else 0.0
return {
'summary': {
'total': total,
'planned': counts['planned'],
'generated': counts['generated'],
'ready': counts['ready'],
'percent_ready': percent,
'media': {
'audio': media_counts['audio'],
'image': media_counts['image'],
'video': media_counts['video'],
'audio_ready': media_ready_counts['audio'],
'image_ready': media_ready_counts['image'],
'video_ready': media_ready_counts['video'],
},
},
'weeks': weeks_payload,
}

View File

@@ -0,0 +1,387 @@
"""Multimedia generation service for course-plan materials.
Three modalities — each persists an ``encoach.course.plan.media`` row
with the bytes attached as an ``ir.attachment`` so the existing
``/web/content/<id>`` URL serving works without extra plumbing.
Audio:
Synthesise a TTS narration of a listening script or speaking
model-answer using AWS Polly (preferred) with a fallback to
ElevenLabs when configured. The voice picks itself from the plan's
target CEFR + a ``voice_key`` param.
Image:
Use OpenAI's DALL-E 3 (via ``OpenAIService.generate_image``) with a
structured prompt built from the material body. Per-plan image
budgets are enforced so a single bad call doesn't bill an admin's
OpenAI account dry.
Video:
Combine a generated image (or, if missing, generate one first)
with the audio narration into an MP4 using a local ``ffmpeg``
subprocess. No third-party rendering service required for the
default install. ffmpeg presence is detected at call time and the
media row is marked ``failed`` with a clear error if it's missing.
The service is deliberately stateless beyond the env handle so it can
be invoked from controllers, agent tools, or batch crons.
"""
from __future__ import annotations
import base64
import io
import logging
import mimetypes
import os
import shutil
import subprocess
import tempfile
import time
from typing import Optional
_logger = logging.getLogger(__name__)
# --- Helpers ----------------------------------------------------------------
def _attach_bytes(env, *, name, mime_type, data: bytes,
res_model: str | None = None,
res_id: int | None = None,
public: bool = True):
"""Persist ``data`` as an ``ir.attachment`` and return the record."""
Attachment = env['ir.attachment'].sudo()
return Attachment.create({
'name': name,
'type': 'binary',
'datas': base64.b64encode(data),
'mimetype': mime_type or 'application/octet-stream',
'res_model': res_model or False,
'res_id': res_id or 0,
'public': bool(public),
})
def _deduce_voice(language: str, gender: str = 'female') -> tuple[str, str]:
"""Return ``(provider, voice_id)`` tuple for the requested language."""
lang = (language or 'en-GB').strip()
return ('polly', '') # let the provider pick its default for the language
def _get_param(env, key, default):
return env['ir.config_parameter'].sudo().get_param(key, default)
def _enforce_image_budget(env, plan, planned_images: int = 1) -> None:
"""Raise if generating ``planned_images`` would exceed the per-plan cap."""
cap = int(_get_param(env, 'encoach_ai_course.image_budget_per_plan', '60'))
if cap <= 0:
return
Media = env['encoach.course.plan.media'].sudo()
used = Media.search_count([
('plan_id', '=', plan.id),
('kind', '=', 'image'),
('status', 'in', ('ready', 'generating')),
])
if used + planned_images > cap:
raise RuntimeError(
f'Image budget exceeded for this plan: {used} used, cap is {cap}. '
f'Raise encoach_ai_course.image_budget_per_plan or delete old images.'
)
def _build_audio_script(material) -> str:
"""Choose the right text from a material's body for narration."""
body = material._loads(material.body_json, {}) or {}
if material.material_type == 'listening_script':
return (body.get('script') or '').strip()
if material.material_type == 'speaking_prompt':
if isinstance(body.get('model_answer'), str) and body['model_answer'].strip():
return body['model_answer'].strip()
prompts = body.get('prompts') or []
if prompts:
return ' '.join(str(p) for p in prompts).strip()
if material.material_type == 'reading_text':
return (body.get('text') or '').strip()
return (material.body_text or '').strip()
def _build_image_prompt(material, *, plan) -> str:
"""Construct a DALL-E prompt from the material content + plan CEFR."""
body = material._loads(material.body_json, {}) or {}
cefr = (plan.cefr_level or '').upper() or 'A2'
style_hint = (
'flat illustration, soft pastel palette, friendly textbook style, '
'clean white background, high contrast, no text, no watermarks'
)
if material.material_type == 'reading_text':
snippet = (body.get('text') or '')[:300].strip()
return (
f'Editorial illustration for an English language reading lesson '
f'(CEFR {cefr}) titled "{material.title}". Subject: {snippet} '
f'Style: {style_hint}.'
)
if material.material_type == 'listening_script':
snippet = (body.get('script') or '')[:300].strip()
return (
f'Illustration showing the scene of an English listening '
f'lesson dialogue (CEFR {cefr}) titled "{material.title}". '
f'Scene: {snippet} Style: {style_hint}.'
)
if material.material_type == 'vocabulary_list':
# Caller should pass a single term explicitly via ``custom_prompt``.
words = body.get('words') or []
if words:
term = words[0].get('term') or material.title
return (
f'Single concrete object representing the English word '
f'"{term}" for a CEFR {cefr} vocabulary flashcard. {style_hint}.'
)
return (
f'Illustration for an English language teaching material (CEFR '
f'{cefr}) titled "{material.title}". {style_hint}.'
)
# --- Public service ---------------------------------------------------------
class MediaService:
"""Generate audio / image / video assets for a course-plan material."""
def __init__(self, env):
self.env = env
# -- Audio -----------------------------------------------------------
def synthesize_audio(self, material, *, voice: Optional[str] = None,
language: str = 'en-GB',
gender: str = 'female',
provider: str = 'polly') -> 'models.Model':
"""Generate a narration MP3 for ``material`` and persist it."""
Media = self.env['encoach.course.plan.media'].sudo()
media = Media.create({
'plan_id': material.plan_id.id,
'week_id': material.week_id.id if material.week_id else False,
'material_id': material.id,
'kind': 'audio',
'provider': provider,
'title': f'{material.title} — narration',
'language': language,
'voice': voice or '',
'status': 'generating',
})
text = _build_audio_script(material)
if not text:
media.write({'status': 'failed', 'error': 'No script text to narrate'})
return media
media.write({'source_text': text[:3000]})
try:
audio_bytes = self._call_tts(
text, voice=voice, language=language,
gender=gender, provider=provider,
)
attach = _attach_bytes(
self.env,
name=f'plan-{material.plan_id.id}-week-{material.week_number}'
f'-{material.material_type}-{material.id}.mp3',
mime_type='audio/mpeg',
data=audio_bytes,
res_model='encoach.course.plan.media',
res_id=media.id,
)
media.write({
'attachment_id': attach.id,
'mime_type': 'audio/mpeg',
'size_bytes': len(audio_bytes),
'status': 'ready',
'error': False,
})
except Exception as exc:
_logger.exception('TTS failed for material %s', material.id)
media.write({'status': 'failed', 'error': str(exc)[:500]})
return media
def _call_tts(self, text, *, voice, language, gender, provider):
if provider == 'elevenlabs':
from odoo.addons.encoach_ai.services.elevenlabs_service import (
ElevenLabsService,
)
svc = ElevenLabsService(self.env)
res = svc.synthesize(text, voice_id=voice or None)
return res.get('audio') or res.get('audio_bytes') or b''
from odoo.addons.encoach_ai.services.polly_service import (
PollyService,
)
svc = PollyService(self.env)
res = svc.synthesize(
text, voice=voice, language=language, gender=gender,
)
return res['audio']
# -- Image -----------------------------------------------------------
def generate_image(self, material, *,
custom_prompt: Optional[str] = None,
size: str = '1024x1024',
style: str = 'natural',
quality: str = 'standard') -> 'models.Model':
"""Generate a DALL-E 3 illustration for ``material``."""
Media = self.env['encoach.course.plan.media'].sudo()
plan = material.plan_id
_enforce_image_budget(self.env, plan, planned_images=1)
prompt = (custom_prompt or _build_image_prompt(material, plan=plan)).strip()
media = Media.create({
'plan_id': plan.id,
'week_id': material.week_id.id if material.week_id else False,
'material_id': material.id,
'kind': 'image',
'provider': 'openai_image',
'title': f'{material.title} — illustration',
'source_text': prompt[:3000],
'style': style,
'status': 'generating',
})
try:
from odoo.addons.encoach_ai.services.openai_service import (
OpenAIService,
)
svc = OpenAIService(self.env)
result = svc.generate_image(
prompt, size=size, style=style, quality=quality,
)
img = result['image']
attach = _attach_bytes(
self.env,
name=f'plan-{plan.id}-week-{material.week_number}'
f'-{material.material_type}-{material.id}.png',
mime_type='image/png',
data=img,
res_model='encoach.course.plan.media',
res_id=media.id,
)
try:
w, h = (int(s) for s in size.split('x'))
except Exception:
w, h = 0, 0
media.write({
'attachment_id': attach.id,
'mime_type': 'image/png',
'size_bytes': len(img),
'width': w,
'height': h,
'status': 'ready',
'error': False,
'cost_cents': 4 if quality == 'standard' else 8,
})
except Exception as exc:
_logger.exception('Image gen failed for material %s', material.id)
media.write({'status': 'failed', 'error': str(exc)[:500]})
return media
# -- Video -----------------------------------------------------------
def compose_video(self, material, *, audio_media=None,
image_media=None) -> 'models.Model':
"""Compose a slide-style MP4 (image + audio) for ``material``.
Auto-creates audio and/or image first if the caller didn't pass
them and they don't already exist on the material. Requires
``ffmpeg`` on PATH; without it the media row is marked failed
with a clear error message.
"""
Media = self.env['encoach.course.plan.media'].sudo()
plan = material.plan_id
media = Media.create({
'plan_id': plan.id,
'week_id': material.week_id.id if material.week_id else False,
'material_id': material.id,
'kind': 'video',
'provider': 'ffmpeg',
'title': f'{material.title} — slideshow',
'status': 'generating',
})
if shutil.which('ffmpeg') is None:
media.write({
'status': 'failed',
'error': 'ffmpeg not found on PATH; install it on the server',
})
return media
try:
audio = audio_media or material.media_ids.filtered(
lambda m: m.kind == 'audio' and m.status == 'ready'
)[:1]
if not audio:
audio = self.synthesize_audio(material)
if audio.status != 'ready':
raise RuntimeError(
f'Audio prerequisite not ready: {audio.error or "unknown"}'
)
image = image_media or material.media_ids.filtered(
lambda m: m.kind == 'image' and m.status == 'ready'
)[:1]
if not image:
image = self.generate_image(material)
if image.status != 'ready':
raise RuntimeError(
f'Image prerequisite not ready: {image.error or "unknown"}'
)
audio_attach = audio.attachment_id
image_attach = image.attachment_id
if not audio_attach or not image_attach:
raise RuntimeError('Missing audio/image attachments')
audio_bytes = base64.b64decode(audio_attach.datas)
image_bytes = base64.b64decode(image_attach.datas)
with tempfile.TemporaryDirectory(prefix='encoach_video_') as tmp:
a_path = os.path.join(tmp, 'audio.mp3')
i_path = os.path.join(tmp, 'image.png')
v_path = os.path.join(tmp, 'out.mp4')
with open(a_path, 'wb') as f:
f.write(audio_bytes)
with open(i_path, 'wb') as f:
f.write(image_bytes)
cmd = [
'ffmpeg', '-y',
'-loop', '1', '-i', i_path,
'-i', a_path,
'-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '192k',
'-shortest', '-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=white',
v_path,
]
t0 = time.time()
proc = subprocess.run(
cmd, capture_output=True, check=False, timeout=180,
)
elapsed = time.time() - t0
if proc.returncode != 0:
err = (proc.stderr or b'').decode('utf-8', errors='replace')[-500:]
raise RuntimeError(f'ffmpeg failed: {err}')
with open(v_path, 'rb') as f:
video_bytes = f.read()
attach = _attach_bytes(
self.env,
name=f'plan-{plan.id}-week-{material.week_number}'
f'-{material.material_type}-{material.id}.mp4',
mime_type='video/mp4',
data=video_bytes,
res_model='encoach.course.plan.media',
res_id=media.id,
)
media.write({
'attachment_id': attach.id,
'mime_type': 'video/mp4',
'size_bytes': len(video_bytes),
'duration_seconds': float(audio.duration_seconds or elapsed or 0),
'width': 1280,
'height': 720,
'status': 'ready',
'error': False,
})
except Exception as exc:
_logger.exception('Video compose failed for material %s', material.id)
media.write({'status': 'failed', 'error': str(exc)[:500]})
return media

View File

@@ -0,0 +1,202 @@
"""Extract text from a course-plan source and embed it into pgvector.
Supported inputs:
* ``kind='file'`` — PDF (preferred via ``pypdf`` then ``PyPDF2``), DOCX
(via ``python-docx``), plain text, or any text MIME we can decode.
* ``kind='url'`` — fetched with ``requests``; HTML is reduced to text
via a tiny BeautifulSoup4 path when available, otherwise raw text.
* ``kind='text'`` — used as-is.
Indexed chunks are stored under ``content_type='course_plan_source'``
with ``entity_id=plan_id`` so the existing ``resources.search`` tool
can scope retrieval to a single plan. We never raise — failures are
recorded on the source row and surfaced to the UI through the status /
error fields. That way one bad PDF doesn't block the rest.
"""
from __future__ import annotations
import io
import logging
from datetime import datetime
_logger = logging.getLogger(__name__)
def _extract_pdf(payload: bytes) -> str:
"""Best-effort PDF text extraction.
Tries ``pypdf`` first (newer, maintained), then ``PyPDF2`` (older,
still common). Both raise on encrypted PDFs we can't decrypt — we
swallow that and let the caller record the error.
"""
try:
from pypdf import PdfReader # type: ignore
except ImportError:
try:
from PyPDF2 import PdfReader # type: ignore
except ImportError as exc:
raise RuntimeError(
'PDF parser not installed (pip install pypdf)',
) from exc
reader = PdfReader(io.BytesIO(payload))
pages = []
for page in reader.pages:
try:
pages.append(page.extract_text() or '')
except Exception:
pages.append('')
return '\n\n'.join(p for p in pages if p).strip()
def _extract_docx(payload: bytes) -> str:
try:
import docx # type: ignore
except ImportError as exc:
raise RuntimeError(
'DOCX parser not installed (pip install python-docx)',
) from exc
document = docx.Document(io.BytesIO(payload))
return '\n'.join(p.text for p in document.paragraphs if p.text).strip()
def _extract_html(html: str) -> str:
"""Strip tags. Uses BeautifulSoup if available, else a regex fallback."""
try:
from bs4 import BeautifulSoup # type: ignore
soup = BeautifulSoup(html, 'html.parser')
for tag in soup(['script', 'style', 'noscript']):
tag.decompose()
text = soup.get_text(separator='\n')
except ImportError:
import re
text = re.sub(r'<[^>]+>', ' ', html)
lines = [line.strip() for line in text.splitlines()]
return '\n'.join(line for line in lines if line)
def _fetch_url(url: str) -> tuple[str, str]:
"""Fetch ``url`` and return ``(content_type, text)``."""
try:
import requests # type: ignore
except ImportError as exc:
raise RuntimeError(
'requests not installed (pip install requests)',
) from exc
resp = requests.get(url, timeout=30, headers={
'User-Agent': 'EnCoach-CoursePlan-Indexer/1.0',
})
resp.raise_for_status()
ctype = (resp.headers.get('Content-Type') or '').split(';')[0].strip().lower()
if ctype == 'application/pdf':
return ctype, _extract_pdf(resp.content)
if 'html' in ctype:
return ctype, _extract_html(resp.text)
if ctype.startswith('text/') or not ctype:
return ctype or 'text/plain', resp.text
raise RuntimeError(f'Unsupported content-type for URL: {ctype}')
class SourceIndexer:
"""Extract text from a source row and (re-)index it to pgvector."""
CONTENT_TYPE = 'course_plan_source'
def __init__(self, env):
self.env = env
def _extract(self, source) -> str:
"""Return raw extracted text — or raise if extraction fails."""
if source.kind == 'text':
return (source.inline_text or '').strip()
if source.kind == 'url':
url = (source.url or '').strip()
if not url:
raise ValueError('URL is empty')
mime, text = _fetch_url(url)
if not source.mime_type:
source.mime_type = mime
return text or ''
if source.kind == 'file':
payload = source.get_decoded_file()
if not payload:
raise ValueError('No file payload to index')
mime = (source.mime_type or '').lower()
name = (source.file_name or '').lower()
if mime == 'application/pdf' or name.endswith('.pdf'):
return _extract_pdf(payload)
if (mime in (
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword',
) or name.endswith('.docx') or name.endswith('.doc')):
return _extract_docx(payload)
try:
return payload.decode('utf-8', errors='replace').strip()
except Exception as exc:
raise RuntimeError(f'Cannot decode file: {exc}') from exc
raise ValueError(f'Unknown source kind: {source.kind!r}')
def index(self, source) -> dict:
"""Run extraction + embedding for one source row."""
from odoo.addons.encoach_vector.services.embedding_service import (
EmbeddingService,
)
source.write({'status': 'indexing', 'error': False})
try:
text = self._extract(source)
except Exception as exc:
source.write({
'status': 'failed',
'error': str(exc)[:500],
'chunks_count': 0,
'extracted_chars': 0,
})
_logger.warning('Extract failed for source %s: %s', source.id, exc)
return {'status': 'failed', 'error': str(exc)}
if not text or not text.strip():
source.write({
'status': 'failed',
'error': 'Extracted no text from source',
'chunks_count': 0,
'extracted_chars': 0,
})
return {'status': 'failed', 'error': 'empty'}
try:
svc = EmbeddingService(self.env)
metadata = {
'plan_id': source.plan_id.id,
'source_id': source.id,
'kind': source.kind,
'title': source.name or source.file_name or source.url or '',
'entity_id': source.plan_id.id,
}
chunks = svc.upsert(
self.CONTENT_TYPE, source.id, text, metadata,
)
except Exception as exc:
source.write({
'status': 'failed',
'error': str(exc)[:500],
})
_logger.exception('Embedding failed for source %s', source.id)
return {'status': 'failed', 'error': str(exc)}
source.write({
'status': 'indexed',
'error': False,
'chunks_count': len(chunks),
'extracted_chars': len(text),
'indexed_at': datetime.utcnow(),
})
return {
'status': 'indexed',
'chunks_count': len(chunks),
'extracted_chars': len(text),
}