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 . import ai_generation_log
from . import ai_ielts_generation_log
from . import course_plan
from . import course_plan_source
from . import course_plan_media
from . import course_plan_assignment

View File

@@ -44,28 +44,10 @@ MATERIAL_TYPE_SELECTION = [
('grammar_lesson', 'Grammar Lesson'),
('vocabulary_list', 'Vocabulary List'),
('practice', 'Practice Exercises'),
# Rich media types for comprehensive teaching materials
('video_lesson', 'Video Lesson'),
('audio_recording', 'Audio Recording'),
('image_visual', 'Image / Visual Aid'),
('interactive', 'Interactive Activity'),
('assessment', 'Assessment / Quiz'),
('other', 'Other'),
]
# Deliverable status tracking
DELIVERABLE_STATUS_SELECTION = [
('planned', 'Planned'),
('generating', 'Generating'),
('generated', 'Generated'),
('reviewing', 'Under Review'),
('approved', 'Approved'),
('delivered', 'Delivered to Students'),
('archived', 'Archived'),
]
class CoursePlan(models.Model):
_name = 'encoach.course.plan'
_description = 'AI-generated Course Plan'
@@ -135,15 +117,30 @@ class CoursePlan(models.Model):
material_ids = fields.One2many(
'encoach.course.plan.material', 'plan_id', string='Materials',
)
source_ids = fields.One2many(
'encoach.course.plan.source', 'plan_id', string='Reference sources',
)
media_ids = fields.One2many(
'encoach.course.plan.media', 'plan_id', string='Generated media',
)
assignment_ids = fields.One2many(
'encoach.course.plan.assignment', 'plan_id', string='Assignments',
)
week_count = fields.Integer(compute='_compute_counts', store=False)
material_count = fields.Integer(compute='_compute_counts', store=False)
source_count = fields.Integer(compute='_compute_counts', store=False)
media_count = fields.Integer(compute='_compute_counts', store=False)
assignment_count = fields.Integer(compute='_compute_counts', store=False)
@api.depends('week_ids', 'material_ids')
@api.depends('week_ids', 'material_ids', 'source_ids', 'media_ids', 'assignment_ids')
def _compute_counts(self):
for rec in self:
rec.week_count = len(rec.week_ids)
rec.material_count = len(rec.material_ids)
rec.source_count = len(rec.source_ids)
rec.media_count = len(rec.media_ids)
rec.assignment_count = len(rec.assignment_ids)
# ------------------------------------------------------------------
# Serialisation helpers — used by the REST controller so payload
@@ -157,7 +154,9 @@ class CoursePlan(models.Model):
except (TypeError, ValueError):
return default
def to_api_dict(self, *, include_weeks=True, include_materials=False):
def to_api_dict(self, *, include_weeks=True, include_materials=False,
include_sources=False, include_media=False,
include_assignments=False):
self.ensure_one()
data = {
'id': self.id,
@@ -177,12 +176,21 @@ class CoursePlan(models.Model):
'resources': self._loads(self.resources_json, []),
'week_count': len(self.week_ids),
'material_count': len(self.material_ids),
'source_count': len(self.source_ids),
'media_count': len(self.media_ids),
'assignment_count': len(self.assignment_ids),
'created_at': self.create_date.isoformat() if self.create_date else None,
}
if include_weeks:
data['weeks'] = [w.to_api_dict() for w in self.week_ids.sorted('week_number')]
if include_materials:
data['materials'] = [m.to_api_dict() for m in self.material_ids]
if include_sources:
data['sources'] = [s.to_api_dict() for s in self.source_ids]
if include_media:
data['media'] = [m.to_api_dict() for m in self.media_ids]
if include_assignments:
data['assignments'] = [a.to_api_dict() for a in self.assignment_ids]
return data
@@ -270,48 +278,8 @@ class CoursePlanMaterial(models.Model):
'structured body is not needed.',
)
def _loads(self, raw, default):
if not raw:
return default
try:
return json.loads(raw)
except (TypeError, ValueError):
return default
# Rich media asset fields for generated content
media_type = fields.Selection([
('none', 'No Media'),
('image', 'Image (AI Generated)'),
('audio', 'Audio (AI Generated)'),
('video', 'Video (AI Generated)'),
('external', 'External Media URL'),
], default='none', string='Media Type')
media_asset_url = fields.Char(
string='Media Asset URL',
help='URL to stored/generated media (image, audio, video)'
)
media_asset_id = fields.Many2one(
'ir.attachment',
string='Media Attachment',
help='Stored media file in Odoo'
)
media_generation_prompt = fields.Text(
string='Media Generation Prompt',
help='The prompt used to generate this media asset'
)
media_metadata_json = fields.Text(
string='Media Metadata',
help='JSON with dimensions, duration, file size, etc.'
)
# Resource dependencies tracking
required_resources_json = fields.Text(
string='Required Resources',
help='JSON list of resource IDs/URLs this material depends on'
)
ai_generation_notes = fields.Text(
string='AI Generation Notes',
help='Internal notes from AI about generation decisions'
media_ids = fields.One2many(
'encoach.course.plan.media', 'material_id', string='Generated media',
)
def _loads(self, raw, default):
@@ -322,9 +290,9 @@ class CoursePlanMaterial(models.Model):
except (TypeError, ValueError):
return default
def to_api_dict(self, include_media=False):
def to_api_dict(self, include_media=True):
self.ensure_one()
data = {
out = {
'id': self.id,
'plan_id': self.plan_id.id,
'week_id': self.week_id.id if self.week_id else None,
@@ -335,315 +303,7 @@ class CoursePlanMaterial(models.Model):
'summary': self.summary or '',
'body': self._loads(self.body_json, {}),
'body_text': self.body_text or '',
'media_type': self.media_type or 'none',
'required_resources': self._loads(self.required_resources_json, []),
}
if include_media:
data['media_asset_url'] = self.media_asset_url or ''
data['media_metadata'] = self._loads(self.media_metadata_json, {})
data['media_generation_prompt'] = self.media_generation_prompt or ''
return data
class CoursePlanDeliverable(models.Model):
"""Explicit deliverable tracking for GE1-style course planning.
Each deliverable represents a specific learning outcome that must be
achieved by students. The AI uses these to generate targeted materials
and track completion status.
"""
_name = 'encoach.course.plan.deliverable'
_description = 'Course Plan Deliverable (Learning Outcome)'
_order = 'plan_id, week_number, skill, code'
plan_id = fields.Many2one(
'encoach.course.plan', required=True, ondelete='cascade', index=True,
)
week_id = fields.Many2one(
'encoach.course.plan.week', ondelete='cascade', index=True,
)
week_number = fields.Integer(required=True, index=True)
# GE1-style outcome identification
code = fields.Char(required=True, index=True, string='Outcome Code')
skill = fields.Selection(SKILL_SELECTION, required=True, index=True)
description = fields.Text(required=True)
cefr_level = fields.Selection([
('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'),
('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'),
], string='CEFR Level')
# Deliverable status and tracking
status = fields.Selection(DELIVERABLE_STATUS_SELECTION, default='planned')
target_date = fields.Date(string='Target Delivery Date')
completion_date = fields.Datetime(string='Completed At')
# AI generation tracking
ai_generated = fields.Boolean(default=False, string='AI Generated')
generation_prompt = fields.Text(string='Generation Prompt Used')
# Resource dependencies for this specific deliverable
resource_dependencies_json = fields.Text(
string='Required Resources',
help='JSON list of {resource_id, name, type, required_for} objects'
)
material_ids = fields.Many2many(
'encoach.course.plan.material',
'deliverable_material_rel',
'deliverable_id', 'material_id',
string='Generated Materials',
help='Materials generated to support this deliverable'
)
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'plan_id': self.plan_id.id,
'week_number': self.week_number,
'code': self.code or '',
'skill': self.skill or '',
'description': self.description or '',
'cefr_level': self.cefr_level or '',
'status': self.status or 'planned',
'target_date': str(self.target_date) if self.target_date else None,
'ai_generated': self.ai_generated,
'resources': json.loads(self.resource_dependencies_json or '[]'),
'material_ids': [m.id for m in self.material_ids],
}
class CoursePlanResourceDependency(models.Model):
"""Track what resources the AI depends on for course generation.
This model stores the relationship between course plans and the external
resources (textbooks, videos, etc.) that the AI references when generating
content. It enables the system to check resource availability before
generation and suggest alternatives if resources are missing.
"""
_name = 'encoach.course.plan.resource.dep'
_description = 'Course Plan Resource Dependency'
_order = 'plan_id, resource_type, name'
plan_id = fields.Many2one(
'encoach.course.plan', required=True, ondelete='cascade', index=True,
)
name = fields.Char(required=True, string='Resource Name')
resource_type = fields.Selection([
('textbook', 'Textbook'),
('workbook', 'Workbook'),
('video', 'Video / Film'),
('audio', 'Audio Recording'),
('website', 'Website / Online Resource'),
('software', 'Software / App'),
('assessment', 'Assessment Bank'),
('other', 'Other'),
], required=True, default='textbook')
# Resource identification
citation = fields.Text(string='Full Citation')
isbn = fields.Char(string='ISBN / Identifier')
url = fields.Char(string='URL / Link')
file_attachment_id = fields.Many2one('ir.attachment', string='Attached File')
# AI dependency tracking
is_required = fields.Boolean(default=True, string='Required for Generation')
is_available = fields.Boolean(default=False, string='Resource Available')
ai_usage_notes = fields.Text(
string='AI Usage Notes',
help='How the AI should use this resource (e.g., "extract dialogue scripts", "reference grammar explanations")'
)
# Content extraction for AI consumption
extracted_content_json = fields.Text(
string='Extracted Content for AI',
help='JSON with key excerpts, chapter summaries, or structured content the AI can reference'
)
# Status
status = fields.Selection([
('needed', 'Needed'),
('sourcing', 'Sourcing'),
('available', 'Available'),
('extracted', 'Content Extracted'),
('unavailable', 'Unavailable'),
('replaced', 'Replaced with Alternative'),
], default='needed', string='Status')
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'plan_id': self.plan_id.id,
'name': self.name or '',
'resource_type': self.resource_type or '',
'citation': self.citation or '',
'is_required': self.is_required,
'is_available': self.is_available,
'status': self.status or 'needed',
'ai_usage_notes': self.ai_usage_notes or '',
'extracted_content': json.loads(self.extracted_content_json or '{}'),
}
class CoursePlanAssignment(models.Model):
"""Assignment of a course plan to classes or individual students.
After a course plan is generated and approved, it can be assigned to
delivery targets (classes, batches, or individual students). This model
tracks the assignment, delivery progress, and completion status.
"""
_name = 'encoach.course.plan.assignment'
_description = 'Course Plan Assignment'
_order = 'create_date desc, id desc'
plan_id = fields.Many2one(
'encoach.course.plan', required=True, ondelete='cascade', index=True,
)
name = fields.Char(compute='_compute_name', store=True)
# Assignment target
assignment_type = fields.Selection([
('class', 'Class / Batch'),
('student', 'Individual Student'),
('group', 'Custom Group'),
], required=True, default='class')
# Link to OpenEduCat models
course_id = fields.Many2one('op.course', string='Course', index=True)
batch_id = fields.Many2one('op.batch', string='Batch / Class', index=True)
student_id = fields.Many2one('op.student', string='Student', index=True)
# Assignment metadata
assigned_by_id = fields.Many2one('res.users', string='Assigned By', default=lambda s: s.env.uid)
assigned_date = fields.Datetime(default=fields.Datetime.now, string='Assigned At')
start_date = fields.Date(string='Start Date')
end_date = fields.Date(string='End Date')
# Delivery status
status = fields.Selection([
('draft', 'Draft'),
('scheduled', 'Scheduled'),
('active', 'Active - In Progress'),
('paused', 'Paused'),
('completed', 'Completed'),
('cancelled', 'Cancelled'),
], default='draft', string='Assignment Status')
# Progress tracking
current_week = fields.Integer(default=1, string='Current Week')
progress_percent = fields.Float(compute='_compute_progress', store=True, string='Progress %')
# Delivery configuration
delivery_mode = fields.Selection([
('sequential', 'Sequential (Week by Week)'),
('parallel', 'Parallel (All Skills)'),
('adaptive', 'Adaptive (AI-Paced)'),
], default='sequential', string='Delivery Mode')
# Notes
notes = fields.Text(string='Assignment Notes')
# Related records
deliverable_ids = fields.One2many(
'encoach.course.plan.assignment.deliverable',
'assignment_id',
string='Deliverable Tracking',
)
@api.depends('plan_id', 'batch_id', 'student_id')
def _compute_name(self):
for rec in self:
parts = []
if rec.plan_id:
parts.append(rec.plan_id.name)
if rec.batch_id:
parts.append(f"{rec.batch_id.name}")
if rec.student_id:
parts.append(f"{rec.student_id.name}")
rec.name = ' '.join(parts) if parts else 'Assignment'
@api.depends('current_week', 'plan_id.total_weeks')
def _compute_progress(self):
for rec in self:
total = rec.plan_id.total_weeks or 1
rec.progress_percent = min(100.0, (rec.current_week / total) * 100)
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'name': self.name or '',
'plan_id': self.plan_id.id if self.plan_id else None,
'plan_name': self.plan_id.name if self.plan_id else '',
'assignment_type': self.assignment_type or '',
'batch_id': self.batch_id.id if self.batch_id else None,
'batch_name': self.batch_id.name if self.batch_id else '',
'student_id': self.student_id.id if self.student_id else None,
'student_name': self.student_id.name if self.student_id else '',
'status': self.status or 'draft',
'start_date': str(self.start_date) if self.start_date else None,
'end_date': str(self.end_date) if self.end_date else None,
'current_week': self.current_week,
'progress_percent': self.progress_percent,
'delivery_mode': self.delivery_mode or 'sequential',
}
class CoursePlanAssignmentDeliverable(models.Model):
"""Per-assignment tracking of deliverable completion.
When a course plan is assigned, this creates a tracking row for each
deliverable so instructors can see which students have completed which
learning outcomes.
"""
_name = 'encoach.course.plan.assignment.deliverable'
_description = 'Assignment Deliverable Status'
_order = 'assignment_id, week_number, code'
assignment_id = fields.Many2one(
'encoach.course.plan.assignment', required=True, ondelete='cascade', index=True,
)
deliverable_id = fields.Many2one(
'encoach.course.plan.deliverable', required=True, ondelete='cascade', index=True,
)
plan_id = fields.Many2one(
related='assignment_id.plan_id', store=True, index=True,
)
# Copy of deliverable info for quick reference
week_number = fields.Integer(related='deliverable_id.week_number', store=True)
code = fields.Char(related='deliverable_id.code', store=True)
skill = fields.Selection(related='deliverable_id.skill', store=True)
description = fields.Text(related='deliverable_id.description', store=True)
# Student completion tracking
status = fields.Selection([
('not_started', 'Not Started'),
('in_progress', 'In Progress'),
('submitted', 'Submitted for Review'),
('completed', 'Completed'),
('exempt', 'Exempt'),
], default='not_started', string='Student Status')
# Progress details
completion_date = fields.Datetime(string='Completed At')
completed_by_id = fields.Many2one('res.users', string='Completed By')
evidence_url = fields.Char(string='Evidence / Submission URL')
score = fields.Float(string='Assessment Score')
notes = fields.Text(string='Instructor Notes')
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'assignment_id': self.assignment_id.id,
'deliverable_id': self.deliverable_id.id,
'week_number': self.week_number,
'code': self.code or '',
'skill': self.skill or '',
'description': self.description or '',
'status': self.status or 'not_started',
'completion_date': self.completion_date.isoformat() if self.completion_date else None,
'score': self.score or 0.0,
}
out['media'] = [m.to_api_dict() for m in self.media_ids]
return out

View File

@@ -0,0 +1,128 @@
"""Course-plan assignments: link a generated plan to a class or students.
Two flavours of assignment:
* ``mode='batch'`` — all current and future students of an ``op.batch``
see the plan in their student dashboard.
* ``mode='students'`` — only the chosen ``res.users`` (linked through
the standard ``op.student`` portal user) see it.
The assignment is the visibility unit; it does NOT mutate
enrollment, grades, or schedules. Teachers can attach a due date and a
short message that appears on the student card.
"""
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
ASSIGNMENT_MODE_SELECTION = [
('batch', 'Class / Batch'),
('students', 'Specific students'),
]
ASSIGNMENT_STATE_SELECTION = [
('active', 'Active'),
('archived', 'Archived'),
]
class CoursePlanAssignment(models.Model):
_name = 'encoach.course.plan.assignment'
_description = 'Course Plan Assignment'
_order = 'create_date desc, id desc'
plan_id = fields.Many2one(
'encoach.course.plan', required=True, ondelete='cascade', index=True,
)
mode = fields.Selection(ASSIGNMENT_MODE_SELECTION, required=True, default='batch')
batch_id = fields.Many2one(
'op.batch', ondelete='set null', string='Class / batch',
)
student_user_ids = fields.Many2many(
'res.users', 'course_plan_assignment_student_rel',
'assignment_id', 'user_id', string='Specific students',
)
assigned_by_id = fields.Many2one(
'res.users', default=lambda self: self.env.user, string='Assigned by',
)
due_date = fields.Date()
message = fields.Text(help='Optional note shown to the student.')
state = fields.Selection(ASSIGNMENT_STATE_SELECTION, default='active')
student_count = fields.Integer(compute='_compute_student_count', store=False)
@api.depends('mode', 'batch_id', 'student_user_ids')
def _compute_student_count(self):
Enroll = self.env['op.student.course'].sudo()
Batch = self.env['op.batch'].sudo()
for rec in self:
if rec.mode == 'students':
rec.student_count = len(rec.student_user_ids)
continue
if not rec.batch_id:
rec.student_count = 0
continue
try:
course_id = Batch.browse(rec.batch_id.id).course_id.id
if course_id:
rec.student_count = Enroll.search_count([
('course_id', '=', course_id),
('batch_id', '=', rec.batch_id.id),
])
else:
rec.student_count = 0
except Exception:
rec.student_count = 0
def expand_user_ids(self):
"""Return the ``res.users`` ids that should see this plan."""
self.ensure_one()
if self.mode == 'students':
return self.student_user_ids.ids
if self.mode == 'batch' and self.batch_id:
try:
Enroll = self.env['op.student.course'].sudo()
course_id = self.batch_id.course_id.id
rows = Enroll.search([
('course_id', '=', course_id),
('batch_id', '=', self.batch_id.id),
])
user_ids = []
for row in rows:
student = getattr(row, 'student_id', None)
if not student:
continue
user = getattr(student, 'user_id', None)
if user and user.id:
user_ids.append(user.id)
return list(set(user_ids))
except Exception:
_logger.exception('expand_user_ids failed for assignment %s', self.id)
return []
return []
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'plan_id': self.plan_id.id,
'plan_name': self.plan_id.name or '',
'mode': self.mode or 'batch',
'batch_id': self.batch_id.id if self.batch_id else None,
'batch_name': self.batch_id.name if self.batch_id else '',
'student_user_ids': self.student_user_ids.ids,
'student_user_names': [u.name for u in self.student_user_ids],
'student_count': self.student_count or 0,
'assigned_by_id': self.assigned_by_id.id if self.assigned_by_id else None,
'assigned_by_name': self.assigned_by_id.name if self.assigned_by_id else '',
'due_date': self.due_date.isoformat() if self.due_date else None,
'message': self.message or '',
'state': self.state or 'active',
'created_at': self.create_date.isoformat() if self.create_date else None,
}

View File

@@ -0,0 +1,124 @@
"""Multimedia training assets generated for a course-plan material.
Each material (a reading text, listening script, vocab list, etc.) can
have one or more linked media rows: an MP3 narration of a listening
script, a DALL-E illustration for a vocab card, an MP4 slideshow video
combining the two, and so on. Bytes live on ``ir.attachment`` so the
existing filestore + access controls + URL serving (``/web/content``)
all work for free.
Media generation is triggered explicitly through REST endpoints — we
never generate inline during plan creation because each call has a
non-trivial latency and cost.
"""
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
MEDIA_KIND_SELECTION = [
('audio', 'Audio'),
('image', 'Image'),
('video', 'Video'),
]
MEDIA_PROVIDER_SELECTION = [
('polly', 'AWS Polly'),
('elevenlabs', 'ElevenLabs'),
('openai_image', 'OpenAI (DALL-E)'),
('ffmpeg', 'ffmpeg (slideshow)'),
('elai', 'Elai.io'),
('manual', 'Manual upload'),
]
MEDIA_STATUS_SELECTION = [
('queued', 'Queued'),
('generating', 'Generating'),
('ready', 'Ready'),
('failed', 'Failed'),
]
class CoursePlanMedia(models.Model):
_name = 'encoach.course.plan.media'
_description = 'Course Plan Multimedia Asset'
_order = 'kind, id desc'
plan_id = fields.Many2one(
'encoach.course.plan', required=True, ondelete='cascade', index=True,
)
week_id = fields.Many2one(
'encoach.course.plan.week', ondelete='cascade', index=True,
)
material_id = fields.Many2one(
'encoach.course.plan.material', ondelete='cascade', index=True,
)
kind = fields.Selection(MEDIA_KIND_SELECTION, required=True)
provider = fields.Selection(MEDIA_PROVIDER_SELECTION, required=True)
title = fields.Char()
source_text = fields.Text(
help='The text fed to the generator (TTS script, image prompt, etc.).',
)
voice = fields.Char()
language = fields.Char(default='en-GB')
style = fields.Char(help='DALL-E style or video template label.')
attachment_id = fields.Many2one(
'ir.attachment', ondelete='set null', string='Binary',
)
mime_type = fields.Char()
size_bytes = fields.Integer()
duration_seconds = fields.Float()
width = fields.Integer()
height = fields.Integer()
download_url = fields.Char(
compute='_compute_download_url', store=False,
help='Web-accessible URL served by Odoo (/web/content/<id>).',
)
status = fields.Selection(MEDIA_STATUS_SELECTION, default='queued')
error = fields.Char()
cost_cents = fields.Integer(
default=0,
help='Approximate provider cost in USD cents at generation time, '
'best-effort accounting for budget caps.',
)
@api.depends('attachment_id')
def _compute_download_url(self):
for rec in self:
rec.download_url = (
f'/web/content/{rec.attachment_id.id}?download=true&filename='
f'{rec.attachment_id.name or "media"}'
if rec.attachment_id else ''
)
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'plan_id': self.plan_id.id,
'week_id': self.week_id.id if self.week_id else None,
'material_id': self.material_id.id if self.material_id else None,
'kind': self.kind or '',
'provider': self.provider or '',
'title': self.title or '',
'voice': self.voice or '',
'language': self.language or '',
'style': self.style or '',
'mime_type': self.mime_type or '',
'size_bytes': self.size_bytes or 0,
'duration_seconds': self.duration_seconds or 0.0,
'width': self.width or 0,
'height': self.height or 0,
'attachment_id': self.attachment_id.id if self.attachment_id else None,
'download_url': self.download_url,
'status': self.status or 'queued',
'error': self.error or '',
'cost_cents': self.cost_cents or 0,
'created_at': self.create_date.isoformat() if self.create_date else None,
}

View File

@@ -0,0 +1,130 @@
"""Reference source attached to a course plan.
Each source is one of: an uploaded file (PDF, DOCX, TXT), a URL the
agent should crawl, or a chunk of inline text. Indexing extracts the
text, chunks it, and pushes the chunks into ``encoach.embedding`` so
the LangGraph ``course_planner`` and ``course_week_materials`` agents
can ground their generation on these sources via the existing
``resources.search`` tool, scoped to the plan.
We deliberately use the existing pgvector store rather than a new one:
that way the same retrieval pipeline (embedding model, chunker, cosine
similarity ranker) is shared, and admins can look up "what did the AI
read" with the same dashboards.
"""
import base64
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
SOURCE_KIND_SELECTION = [
('file', 'File'),
('url', 'URL'),
('text', 'Inline text'),
]
SOURCE_STATUS_SELECTION = [
('pending', 'Pending'),
('indexing', 'Indexing'),
('indexed', 'Indexed'),
('failed', 'Failed'),
]
class CoursePlanSource(models.Model):
_name = 'encoach.course.plan.source'
_description = 'Course Plan Reference Source'
_order = 'sequence asc, id asc'
plan_id = fields.Many2one(
'encoach.course.plan', required=True, ondelete='cascade', index=True,
)
sequence = fields.Integer(default=10)
name = fields.Char(required=True, help='Human-readable label.')
kind = fields.Selection(SOURCE_KIND_SELECTION, required=True, default='file')
# Filled when ``kind == 'file'`` — base64 payload mirrors how
# encoach.resource and ir.attachment already do it.
file = fields.Binary(string='Uploaded file', attachment=True)
file_name = fields.Char(string='File name')
mime_type = fields.Char(string='MIME type')
url = fields.Char(string='Source URL')
inline_text = fields.Text(string='Inline text')
auto_index = fields.Boolean(
default=True,
help='If true, indexing runs automatically on create. '
'Disable to upload first, review, then index manually.',
)
status = fields.Selection(SOURCE_STATUS_SELECTION, default='pending')
error = fields.Char()
chunks_count = fields.Integer(default=0, string='Chunks')
extracted_chars = fields.Integer(default=0, string='Extracted chars')
indexed_at = fields.Datetime()
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for rec in records:
if rec.auto_index:
try:
rec.action_index()
except Exception:
_logger.exception('Auto-index failed for source %s', rec.id)
return records
def action_index(self):
"""(Re-)extract text and push chunks to the vector store."""
from odoo.addons.encoach_ai_course.services.source_indexer import (
SourceIndexer,
)
for rec in self:
indexer = SourceIndexer(self.env)
indexer.index(rec)
return True
def unlink(self):
try:
from odoo.addons.encoach_vector.services.embedding_service import (
EmbeddingService,
)
svc = EmbeddingService(self.env)
for rec in self:
svc.delete('course_plan_source', rec.id)
except Exception:
_logger.exception('Failed to delete embeddings for sources')
return super().unlink()
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'plan_id': self.plan_id.id,
'name': self.name or '',
'kind': self.kind or 'file',
'file_name': self.file_name or '',
'mime_type': self.mime_type or '',
'url': self.url or '',
'has_inline_text': bool(self.inline_text),
'status': self.status or 'pending',
'error': self.error or '',
'chunks_count': self.chunks_count or 0,
'extracted_chars': self.extracted_chars or 0,
'indexed_at': self.indexed_at.isoformat() if self.indexed_at else None,
'created_at': self.create_date.isoformat() if self.create_date else None,
}
def get_decoded_file(self):
"""Return raw bytes of the uploaded file, or ``b''`` if none."""
self.ensure_one()
if not self.file:
return b''
try:
return base64.b64decode(self.file)
except Exception:
return b''