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:
@@ -99,6 +99,37 @@
|
|||||||
<field name="sequence">90</field>
|
<field name="sequence">90</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- Media generation -->
|
||||||
|
<record id="ai_tool_media_audio" model="encoach.ai.tool">
|
||||||
|
<field name="key">media.synthesize_audio</field>
|
||||||
|
<field name="name">Synthesize narration audio</field>
|
||||||
|
<field name="category">media</field>
|
||||||
|
<field name="mutates" eval="True"/>
|
||||||
|
<field name="description">Render an MP3 narration of a material's text using AWS Polly (default) or ElevenLabs. Used for listening_script and speaking_prompt materials. Returns the media id, status and a /web/content URL when ready.</field>
|
||||||
|
<field name="schema_json">{"type":"object","properties":{"material_id":{"type":"integer"},"voice":{"type":"string"},"language":{"type":"string","default":"en-GB"},"gender":{"type":"string","enum":["female","male"]},"provider":{"type":"string","enum":["polly","elevenlabs"]}},"required":["material_id"]}</field>
|
||||||
|
<field name="sequence">120</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="ai_tool_media_image" model="encoach.ai.tool">
|
||||||
|
<field name="key">media.generate_image</field>
|
||||||
|
<field name="name">Generate illustration (DALL-E)</field>
|
||||||
|
<field name="category">media</field>
|
||||||
|
<field name="mutates" eval="True"/>
|
||||||
|
<field name="description">Generate a single PNG illustration with OpenAI DALL-E 3 for a course-plan material. Used for reading hero images and vocabulary flashcards. Honours the per-plan image budget (encoach_ai_course.image_budget_per_plan).</field>
|
||||||
|
<field name="schema_json">{"type":"object","properties":{"material_id":{"type":"integer"},"prompt":{"type":"string"},"size":{"type":"string","enum":["1024x1024","1024x1792","1792x1024"]},"style":{"type":"string","enum":["natural","vivid"]},"quality":{"type":"string","enum":["standard","hd"]}},"required":["material_id"]}</field>
|
||||||
|
<field name="sequence">130</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="ai_tool_media_video" model="encoach.ai.tool">
|
||||||
|
<field name="key">media.compose_video</field>
|
||||||
|
<field name="name">Compose slideshow video (ffmpeg)</field>
|
||||||
|
<field name="category">media</field>
|
||||||
|
<field name="mutates" eval="True"/>
|
||||||
|
<field name="description">Combine an existing audio narration with a still image into an MP4 (1280x720) using a local ffmpeg subprocess. Auto-creates audio and/or image first if the material lacks them. Falls back gracefully when ffmpeg is missing on the server.</field>
|
||||||
|
<field name="schema_json">{"type":"object","properties":{"material_id":{"type":"integer"}},"required":["material_id"]}</field>
|
||||||
|
<field name="sequence">140</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
<!-- Scoring -->
|
<!-- Scoring -->
|
||||||
<record id="ai_tool_scoring_writing" model="encoach.ai.tool">
|
<record id="ai_tool_scoring_writing" model="encoach.ai.tool">
|
||||||
<field name="key">scoring.grade_writing</field>
|
<field name="key">scoring.grade_writing</field>
|
||||||
@@ -118,92 +149,6 @@
|
|||||||
<field name="sequence">110</field>
|
<field name="sequence">110</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- Course Planning & Deliverable Detection -->
|
|
||||||
<record id="ai_tool_deliverables_detect" model="encoach.ai.tool">
|
|
||||||
<field name="key">deliverables.detect</field>
|
|
||||||
<field name="name">Detect deliverables from outline</field>
|
|
||||||
<field name="category">reference</field>
|
|
||||||
<field name="description">Parse a course outline (like GE1 PDF) and extract structured learning outcomes/deliverables week by week. Returns deliverable codes, skills, descriptions, and resource hints.</field>
|
|
||||||
<field name="schema_json">{"type":"object","properties":{"course_outline_text":{"type":"string","description":"Full text of course outline (PDF extracted)"},"cefr_level":{"type":"string"},"total_weeks":{"type":"integer"}},"required":["course_outline_text"]}</field>
|
|
||||||
<field name="sequence">120</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="ai_tool_deliverables_fetch" model="encoach.ai.tool">
|
|
||||||
<field name="key">deliverables.fetch</field>
|
|
||||||
<field name="name">Fetch plan deliverables</field>
|
|
||||||
<field name="category">reference</field>
|
|
||||||
<field name="description">Fetch deliverables for a course plan so AI can reference them when generating materials.</field>
|
|
||||||
<field name="schema_json">{"type":"object","properties":{"plan_id":{"type":"integer"},"week_number":{"type":"integer"},"skill":{"type":"string"}}}</field>
|
|
||||||
<field name="sequence">121</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="ai_tool_resources_fetch" model="encoach.ai.tool">
|
|
||||||
<field name="key">resources.fetch</field>
|
|
||||||
<field name="name">Fetch resource dependencies</field>
|
|
||||||
<field name="category">reference</field>
|
|
||||||
<field name="description">Fetch resource dependencies for a course plan (textbooks, videos, etc.) so AI knows what's available to reference.</field>
|
|
||||||
<field name="schema_json">{"type":"object","properties":{"plan_id":{"type":"integer"},"resource_type":{"type":"string"},"is_available":{"type":"boolean"}}}</field>
|
|
||||||
<field name="sequence">122</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="ai_tool_resources_save" model="encoach.ai.tool">
|
|
||||||
<field name="key">resources.save</field>
|
|
||||||
<field name="name">Save resource dependency</field>
|
|
||||||
<field name="category">persistence</field>
|
|
||||||
<field name="mutates" eval="True"/>
|
|
||||||
<field name="description">Save a resource dependency for a course plan.</field>
|
|
||||||
<field name="schema_json">{"type":"object","properties":{"plan_id":{"type":"integer"},"name":{"type":"string"},"resource_type":{"type":"string"},"citation":{"type":"string"},"ai_usage_notes":{"type":"string"}},"required":["plan_id","name"]}</field>
|
|
||||||
<field name="sequence">123</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Media Generation Tools -->
|
|
||||||
<record id="ai_tool_media_suggest_visuals" model="encoach.ai.tool">
|
|
||||||
<field name="key">media.suggest_visuals</field>
|
|
||||||
<field name="name">Suggest visual aids</field>
|
|
||||||
<field name="category">custom</field>
|
|
||||||
<field name="description">Suggest what images, diagrams, or visuals would enhance a teaching material. Returns descriptions and prompts for image generation.</field>
|
|
||||||
<field name="schema_json">{"type":"object","properties":{"content_description":{"type":"string"},"material_type":{"type":"string"},"target_audience":{"type":"string"}}}</field>
|
|
||||||
<field name="sequence">130</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="ai_tool_media_generate_image" model="encoach.ai.tool">
|
|
||||||
<field name="key">media.generate_image</field>
|
|
||||||
<field name="name">Generate educational image</field>
|
|
||||||
<field name="category">custom</field>
|
|
||||||
<field name="description">Generate an educational image using AI (DALL-E/Stable Diffusion) for teaching materials.</field>
|
|
||||||
<field name="schema_json">{"type":"object","properties":{"prompt":{"type":"string"},"material_id":{"type":"integer"},"style":{"type":"string"}}}</field>
|
|
||||||
<field name="sequence">131</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="ai_tool_media_generate_audio" model="encoach.ai.tool">
|
|
||||||
<field name="key">media.generate_audio</field>
|
|
||||||
<field name="name">Generate audio (TTS)</field>
|
|
||||||
<field name="category">custom</field>
|
|
||||||
<field name="description">Generate audio using TTS (ElevenLabs, AWS Polly) for listening scripts or pronunciation guides.</field>
|
|
||||||
<field name="schema_json">{"type":"object","properties":{"text":{"type":"string"},"voice":{"type":"string"},"material_id":{"type":"integer"},"purpose":{"type":"string"}}}</field>
|
|
||||||
<field name="sequence">132</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Assignment & Delivery Tracking -->
|
|
||||||
<record id="ai_tool_assignment_create" model="encoach.ai.tool">
|
|
||||||
<field name="key">assignment.create</field>
|
|
||||||
<field name="name">Create course assignment</field>
|
|
||||||
<field name="category">persistence</field>
|
|
||||||
<field name="mutates" eval="True"/>
|
|
||||||
<field name="description">Assign a course plan to a class or student and create deliverable tracking rows.</field>
|
|
||||||
<field name="schema_json">{"type":"object","properties":{"plan_id":{"type":"integer"},"assignment_type":{"type":"string"},"batch_id":{"type":"integer"},"student_id":{"type":"integer"},"start_date":{"type":"string"},"delivery_mode":{"type":"string"}},"required":["plan_id"]}</field>
|
|
||||||
<field name="sequence">140</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="ai_tool_assignment_progress" model="encoach.ai.tool">
|
|
||||||
<field name="key">assignment.progress</field>
|
|
||||||
<field name="name">Get assignment progress</field>
|
|
||||||
<field name="category">reference</field>
|
|
||||||
<field name="description">Get progress summary for a course plan assignment.</field>
|
|
||||||
<field name="schema_json">{"type":"object","properties":{"assignment_id":{"type":"integer"}},"required":["assignment_id"]}</field>
|
|
||||||
<field name="sequence">141</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- ============================== AGENTS ============================== -->
|
<!-- ============================== AGENTS ============================== -->
|
||||||
|
|
||||||
<!-- 1. Course planner -->
|
<!-- 1. Course planner -->
|
||||||
@@ -233,37 +178,6 @@ Rules:
|
|||||||
ref('ai_tool_resources_search'),
|
ref('ai_tool_resources_search'),
|
||||||
ref('ai_tool_quality_cefr'),
|
ref('ai_tool_quality_cefr'),
|
||||||
ref('ai_tool_course_plan_save'),
|
ref('ai_tool_course_plan_save'),
|
||||||
ref('ai_tool_deliverables_fetch'),
|
|
||||||
ref('ai_tool_resources_fetch'),
|
|
||||||
])]"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- 1b. Course deliverable detector (GE1-style outline parser) -->
|
|
||||||
<record id="ai_agent_deliverable_detector" model="encoach.ai.agent">
|
|
||||||
<field name="key">deliverable_detector</field>
|
|
||||||
<field name="name">Course Outline Deliverable Detector</field>
|
|
||||||
<field name="description">Parses course outlines (like GE1 PDF) and extracts structured deliverables (learning outcomes by week). Creates deliverable records with skill codes, descriptions, and resource dependencies.</field>
|
|
||||||
<field name="model">gpt-4o</field>
|
|
||||||
<field name="fallback_model">gpt-4o-mini</field>
|
|
||||||
<field name="temperature">0.3</field>
|
|
||||||
<field name="max_tokens">6000</field>
|
|
||||||
<field name="response_format">json</field>
|
|
||||||
<field name="graph_type">simple</field>
|
|
||||||
<field name="max_revisions">0</field>
|
|
||||||
<field name="quality_checks"></field>
|
|
||||||
<field name="sequence">15</field>
|
|
||||||
<field name="system_prompt">You are a curriculum analysis AI that extracts structured learning outcomes from course outlines (like UTAS GE1 format).
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Identify all learning outcomes by skill area (Reading, Writing, Listening, Speaking, Vocabulary, Grammar)
|
|
||||||
- Assign week numbers based on the delivery schedule in the outline
|
|
||||||
- Create outcome codes (RLO1, WLO1, LLO1, SLO1, VLO1, GLO1, etc.)
|
|
||||||
- Extract resource references (textbooks, supplementary materials)
|
|
||||||
- Identify skills time division (e.g., "10 hrs Reading/Writing + 8 hrs Listening/Speaking")
|
|
||||||
- Output valid JSON with deliverables[], resources_needed[], skills_breakdown{}</field>
|
|
||||||
<field name="tool_ids" eval="[(6, 0, [
|
|
||||||
ref('ai_tool_deliverables_detect'),
|
|
||||||
ref('ai_tool_resources_save'),
|
|
||||||
])]"/>
|
])]"/>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
@@ -290,49 +204,14 @@ Rules:
|
|||||||
- Speaking prompts include useful-language chunks the learner can recycle.
|
- Speaking prompts include useful-language chunks the learner can recycle.
|
||||||
- Grammar lesson: one clear rule + 3 examples + 5 practice items with answer keys.
|
- Grammar lesson: one clear rule + 3 examples + 5 practice items with answer keys.
|
||||||
- Vocabulary: 8-12 entries with part of speech, CEFR-appropriate definition, and an example sentence in context.
|
- Vocabulary: 8-12 entries with part of speech, CEFR-appropriate definition, and an example sentence in context.
|
||||||
- Output valid JSON only; no prose or markdown around it.
|
- Output valid JSON only; no prose or markdown around it.</field>
|
||||||
|
|
||||||
When generating:
|
|
||||||
1. First call deliverables.fetch to see what learning outcomes this week must address
|
|
||||||
2. Call resources.fetch to see what textbooks/materials are available to reference
|
|
||||||
3. Generate materials that specifically target the deliverables using the resources</field>
|
|
||||||
<field name="tool_ids" eval="[(6, 0, [
|
<field name="tool_ids" eval="[(6, 0, [
|
||||||
ref('ai_tool_outcomes_fetch'),
|
ref('ai_tool_outcomes_fetch'),
|
||||||
ref('ai_tool_resources_search'),
|
ref('ai_tool_resources_search'),
|
||||||
ref('ai_tool_quality_cefr'),
|
ref('ai_tool_quality_cefr'),
|
||||||
ref('ai_tool_course_plan_save_materials'),
|
ref('ai_tool_course_plan_save_materials'),
|
||||||
ref('ai_tool_deliverables_fetch'),
|
ref('ai_tool_media_audio'),
|
||||||
ref('ai_tool_resources_fetch'),
|
ref('ai_tool_media_image'),
|
||||||
ref('ai_tool_media_suggest_visuals'),
|
|
||||||
])]"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- 2b. Rich Media Generator -->
|
|
||||||
<record id="ai_agent_media_generator" model="encoach.ai.agent">
|
|
||||||
<field name="key">media_generator</field>
|
|
||||||
<field name="name">Rich Media Generator</field>
|
|
||||||
<field name="description">Generates images, audio, and video suggestions for teaching materials. Uses DALL-E for images, TTS for audio, and suggests video content.</field>
|
|
||||||
<field name="model">gpt-4o</field>
|
|
||||||
<field name="fallback_model">gpt-4o-mini</field>
|
|
||||||
<field name="temperature">0.6</field>
|
|
||||||
<field name="max_tokens">2000</field>
|
|
||||||
<field name="response_format">json</field>
|
|
||||||
<field name="graph_type">simple</field>
|
|
||||||
<field name="max_revisions">0</field>
|
|
||||||
<field name="quality_checks"></field>
|
|
||||||
<field name="sequence">25</field>
|
|
||||||
<field name="system_prompt">You are an educational media designer. You create visual and audio assets that enhance language learning materials.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- For images: Create clear, educational illustrations suitable for the CEFR level
|
|
||||||
- For audio: Generate natural, clearly articulated speech for listening exercises
|
|
||||||
- Always describe the learning purpose of each media asset
|
|
||||||
- Include generation prompts that can be used with DALL-E, ElevenLabs, etc.
|
|
||||||
- Output valid JSON with media_type, prompt, learning_purpose, suggested_dimensions</field>
|
|
||||||
<field name="tool_ids" eval="[(6, 0, [
|
|
||||||
ref('ai_tool_media_suggest_visuals'),
|
|
||||||
ref('ai_tool_media_generate_image'),
|
|
||||||
ref('ai_tool_media_generate_audio'),
|
|
||||||
])]"/>
|
])]"/>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
@@ -482,6 +361,46 @@ Rules:
|
|||||||
])]"/>
|
])]"/>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- 8. Course media director -->
|
||||||
|
<record id="ai_agent_course_media_director" model="encoach.ai.agent">
|
||||||
|
<field name="key">course_media_director</field>
|
||||||
|
<field name="name">Course Media Director</field>
|
||||||
|
<field name="description">Given a generated week of teaching materials, decides which media (audio narration, illustrations, slideshow video) each material needs and orchestrates their generation through the media tools.</field>
|
||||||
|
<field name="model">gpt-4o-mini</field>
|
||||||
|
<field name="fallback_model">gpt-4o</field>
|
||||||
|
<field name="temperature">0.3</field>
|
||||||
|
<field name="max_tokens">2000</field>
|
||||||
|
<field name="response_format">text</field>
|
||||||
|
<field name="graph_type">react</field>
|
||||||
|
<field name="max_revisions">0</field>
|
||||||
|
<field name="quality_checks"></field>
|
||||||
|
<field name="sequence">80</field>
|
||||||
|
<field name="system_prompt">You are the multimedia director for an English language course. Given the materials of one week, you decide what media each material needs, then call the matching tools.
|
||||||
|
|
||||||
|
Default policy:
|
||||||
|
- listening_script -> media.synthesize_audio (mandatory) + media.generate_image (optional, scene illustration) + media.compose_video (optional, slideshow)
|
||||||
|
- speaking_prompt -> media.synthesize_audio for the model answer (optional)
|
||||||
|
- reading_text -> media.generate_image for a hero illustration (optional)
|
||||||
|
- vocabulary_list -> media.generate_image for the first 1-3 terms (use vocab term in the prompt)
|
||||||
|
- writing_prompt / grammar_lesson -> usually no media
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Always check if a material already has ready media of a given kind before regenerating; if so, skip.
|
||||||
|
- Stop after issuing the planned tool calls; never invent material ids.
|
||||||
|
- When done, emit a short text summary listing what was generated (media_id, status, error if any).</field>
|
||||||
|
<field name="tool_ids" eval="[(6, 0, [
|
||||||
|
ref('ai_tool_media_audio'),
|
||||||
|
ref('ai_tool_media_image'),
|
||||||
|
ref('ai_tool_media_video'),
|
||||||
|
])]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Default per-plan image budget. Adjust in System Parameters. -->
|
||||||
|
<record id="ai_default_image_budget" model="ir.config_parameter">
|
||||||
|
<field name="key">encoach_ai_course.image_budget_per_plan</field>
|
||||||
|
<field name="value">60</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
<!-- Feature flag: pipelines consult this before routing through AgentRuntime.
|
<!-- Feature flag: pipelines consult this before routing through AgentRuntime.
|
||||||
Default "True" so the defaults-ship-working contract holds. -->
|
Default "True" so the defaults-ship-working contract holds. -->
|
||||||
<record id="ai_default_use_langgraph" model="ir.config_parameter">
|
<record id="ai_default_use_langgraph" model="ir.config_parameter">
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ TOOL_CATEGORIES = [
|
|||||||
("quality", "Quality & gating"),
|
("quality", "Quality & gating"),
|
||||||
("scoring", "Scoring & grading"),
|
("scoring", "Scoring & grading"),
|
||||||
("reference", "Reference lookup"),
|
("reference", "Reference lookup"),
|
||||||
|
("media", "Media generation"),
|
||||||
("other", "Other"),
|
("other", "Other"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -88,20 +88,31 @@ def invoke(env, key: str, params: dict | None = None) -> dict:
|
|||||||
# --- Retrieval ----------------------------------------------------------------
|
# --- Retrieval ----------------------------------------------------------------
|
||||||
@register("resources.search")
|
@register("resources.search")
|
||||||
def _search_resources(env, query: str = "", skill: str = "", cefr_level: str = "",
|
def _search_resources(env, query: str = "", skill: str = "", cefr_level: str = "",
|
||||||
limit: int = 5, **_: Any) -> dict:
|
limit: int = 5, plan_id: int | None = None,
|
||||||
|
**_: Any) -> dict:
|
||||||
"""Semantic search over the LMS resource library.
|
"""Semantic search over the LMS resource library.
|
||||||
|
|
||||||
|
When ``plan_id`` is provided, retrieval is scoped to that plan's
|
||||||
|
indexed reference sources only (uploaded PDFs, URLs, inline text).
|
||||||
|
Without ``plan_id`` we search the global resource library.
|
||||||
|
|
||||||
Returns titles + short snippets so the agent can cite existing
|
Returns titles + short snippets so the agent can cite existing
|
||||||
materials instead of inventing new ones every run.
|
materials instead of inventing new ones every run.
|
||||||
"""
|
"""
|
||||||
from odoo.addons.encoach_vector.services.embedding_service import (
|
from odoo.addons.encoach_vector.services.embedding_service import (
|
||||||
EmbeddingService, # noqa: F401
|
EmbeddingService,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
svc = EmbeddingService(env)
|
svc = EmbeddingService(env)
|
||||||
# EmbeddingService.search is expected to filter by content_type;
|
if plan_id:
|
||||||
# we accept a skill filter from the agent but don't require it.
|
results = svc.search(
|
||||||
results = svc.search(query or "", limit=int(limit or 5))
|
query or "",
|
||||||
|
content_type="course_plan_source",
|
||||||
|
entity_id=int(plan_id),
|
||||||
|
limit=int(limit or 5),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
results = svc.search(query or "", limit=int(limit or 5))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.debug("resource vector search unavailable: %s", exc)
|
_logger.debug("resource vector search unavailable: %s", exc)
|
||||||
results = []
|
results = []
|
||||||
@@ -114,7 +125,7 @@ def _search_resources(env, query: str = "", skill: str = "", cefr_level: str = "
|
|||||||
"snippet": (r.get("text") or "")[:400],
|
"snippet": (r.get("text") or "")[:400],
|
||||||
"similarity": r.get("similarity"),
|
"similarity": r.get("similarity"),
|
||||||
})
|
})
|
||||||
return {"query": query, "count": len(out), "items": out}
|
return {"query": query, "plan_id": plan_id, "count": len(out), "items": out}
|
||||||
|
|
||||||
|
|
||||||
@register("rubric.fetch")
|
@register("rubric.fetch")
|
||||||
@@ -326,433 +337,82 @@ def _grade_speaking(env, rubric: str = "", transcript: str = "", **_: Any) -> di
|
|||||||
return {"error": str(exc)}
|
return {"error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
# --- Deliverable Detection & Resource Management (GE1-style course planning) ---
|
# --- Media generation tools ---------------------------------------------------
|
||||||
|
#
|
||||||
|
# These bridge an agent decision (e.g. "this listening lesson needs an MP3")
|
||||||
|
# into the MediaService implementation. They are mutating tools — the seed
|
||||||
|
# row in agents_defaults.xml has ``mutates=True`` so the runtime wraps them
|
||||||
|
# in a savepoint.
|
||||||
|
|
||||||
@register("deliverables.detect")
|
@register("media.synthesize_audio")
|
||||||
def _detect_deliverables(env, course_outline_text: str = "", cefr_level: str = "",
|
def _media_synthesize_audio(env, material_id: int, voice: str = "",
|
||||||
total_weeks: int = 12, **_: Any) -> dict:
|
language: str = "en-GB",
|
||||||
"""Parse a course outline (like GE1) and extract structured deliverables.
|
gender: str = "female",
|
||||||
|
provider: str = "polly", **_: Any) -> dict:
|
||||||
|
Material = env["encoach.course.plan.material"].sudo() \
|
||||||
|
if "encoach.course.plan.material" in env else None
|
||||||
|
if Material is None:
|
||||||
|
return {"error": "course_plan_material_model_missing"}
|
||||||
|
rec = Material.browse(int(material_id))
|
||||||
|
if not rec.exists():
|
||||||
|
return {"error": "material_not_found"}
|
||||||
|
from odoo.addons.encoach_ai_course.services.media_service import MediaService
|
||||||
|
svc = MediaService(env)
|
||||||
|
media = svc.synthesize_audio(
|
||||||
|
rec, voice=voice or None, language=language or "en-GB",
|
||||||
|
gender=gender or "female", provider=provider or "polly",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"media_id": media.id,
|
||||||
|
"status": media.status,
|
||||||
|
"download_url": media.download_url,
|
||||||
|
"error": media.error or None,
|
||||||
|
}
|
||||||
|
|
||||||
Returns a list of week-by-week learning outcomes that the AI can use
|
|
||||||
to generate targeted materials. Each deliverable includes skill, outcome
|
|
||||||
code, description, and suggested resource dependencies.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Use OpenAI to parse the outline and extract deliverables
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
svc = OpenAIService(env)
|
|
||||||
|
|
||||||
prompt = f"""Analyze this course outline and extract ALL learning outcomes/deliverables.
|
|
||||||
|
|
||||||
Course Outline:
|
|
||||||
{course_outline_text[:8000]}
|
|
||||||
|
|
||||||
Extract deliverables in this JSON format:
|
|
||||||
{{
|
|
||||||
"deliverables": [
|
|
||||||
{{
|
|
||||||
"week_number": 1,
|
|
||||||
"code": "RLO1",
|
|
||||||
"skill": "reading",
|
|
||||||
"description": "Use pre-reading strategies to preview...",
|
|
||||||
"cefr_level": "{cefr_level or 'a2'}",
|
|
||||||
"resource_hints": ["textbook_chapter", "visual_aid"]
|
|
||||||
}}
|
|
||||||
],
|
|
||||||
"resources_needed": [
|
|
||||||
{{"type": "textbook", "title": "...", "purpose": "..."}}
|
|
||||||
],
|
|
||||||
"skills_breakdown": {{
|
|
||||||
"reading": {{"hours_per_week": 5, "outcomes_count": 12}},
|
|
||||||
"listening": {{"hours_per_week": 4, "outcomes_count": 12}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
|
|
||||||
Focus on extracting:
|
|
||||||
1. All numbered learning outcomes by skill area
|
|
||||||
2. Which week each outcome should be delivered
|
|
||||||
3. What resources are referenced (textbooks, materials)
|
|
||||||
4. Skills time division (e.g., "10 hrs Reading/Writing + 8 hrs Listening/Speaking")
|
|
||||||
|
|
||||||
Return valid JSON only."""
|
|
||||||
|
|
||||||
result = svc.chat_json([
|
|
||||||
{"role": "system", "content": "You are a curriculum analysis AI. Extract structured learning outcomes from course outlines."},
|
|
||||||
{"role": "user", "content": prompt}
|
|
||||||
], temperature=0.3, max_tokens=4000)
|
|
||||||
|
|
||||||
if result and 'deliverables' in result:
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"deliverables_count": len(result.get('deliverables', [])),
|
|
||||||
"deliverables": result.get('deliverables', []),
|
|
||||||
"resources_needed": result.get('resources_needed', []),
|
|
||||||
"skills_breakdown": result.get('skills_breakdown', {}),
|
|
||||||
"note": "Deliverables extracted from course outline"
|
|
||||||
}
|
|
||||||
return {"ok": False, "error": "Could not parse deliverables", "raw": result}
|
|
||||||
except Exception as exc:
|
|
||||||
_logger.exception("deliverables.detect failed")
|
|
||||||
return {"ok": False, "error": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
@register("deliverables.fetch")
|
|
||||||
def _fetch_deliverables(env, plan_id: int | None = None, week_number: int | None = None,
|
|
||||||
skill: str = "", **_: Any) -> dict:
|
|
||||||
"""Fetch deliverables for a course plan (for AI to reference when generating)."""
|
|
||||||
try:
|
|
||||||
Deliverable = env["encoach.course.plan.deliverable"].sudo()
|
|
||||||
if not Deliverable:
|
|
||||||
return {"error": "deliverable_model_missing"}
|
|
||||||
|
|
||||||
domain = []
|
|
||||||
if plan_id:
|
|
||||||
domain.append(("plan_id", "=", int(plan_id)))
|
|
||||||
if week_number:
|
|
||||||
domain.append(("week_number", "=", int(week_number)))
|
|
||||||
if skill:
|
|
||||||
domain.append(("skill", "=", skill))
|
|
||||||
|
|
||||||
records = Deliverable.search(domain, limit=200)
|
|
||||||
items = []
|
|
||||||
for r in records:
|
|
||||||
items.append({
|
|
||||||
"id": r.id,
|
|
||||||
"plan_id": r.plan_id.id,
|
|
||||||
"week_number": r.week_number,
|
|
||||||
"code": r.code or '',
|
|
||||||
"skill": r.skill or '',
|
|
||||||
"description": r.description or '',
|
|
||||||
"cefr_level": r.cefr_level or '',
|
|
||||||
"status": r.status or 'planned',
|
|
||||||
"resources": json.loads(r.resource_dependencies_json or '[]'),
|
|
||||||
})
|
|
||||||
return {"ok": True, "count": len(items), "deliverables": items}
|
|
||||||
except Exception as exc:
|
|
||||||
_logger.exception("deliverables.fetch failed")
|
|
||||||
return {"error": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
@register("resources.fetch")
|
|
||||||
def _fetch_resources(env, plan_id: int | None = None, resource_type: str = "",
|
|
||||||
is_available: bool | None = None, **_: Any) -> dict:
|
|
||||||
"""Fetch resource dependencies for a course plan.
|
|
||||||
|
|
||||||
The AI uses this to check what textbooks, videos, etc. are available
|
|
||||||
before generating content that references them.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
ResourceDep = env["encoach.course.plan.resource.dep"].sudo()
|
|
||||||
if not ResourceDep:
|
|
||||||
return {"error": "resource_dep_model_missing"}
|
|
||||||
|
|
||||||
domain = []
|
|
||||||
if plan_id:
|
|
||||||
domain.append(("plan_id", "=", int(plan_id)))
|
|
||||||
if resource_type:
|
|
||||||
domain.append(("resource_type", "=", resource_type))
|
|
||||||
if is_available is not None:
|
|
||||||
domain.append(("is_available", "=", bool(is_available)))
|
|
||||||
|
|
||||||
records = ResourceDep.search(domain, limit=100)
|
|
||||||
items = []
|
|
||||||
for r in records:
|
|
||||||
items.append({
|
|
||||||
"id": r.id,
|
|
||||||
"plan_id": r.plan_id.id,
|
|
||||||
"name": r.name or '',
|
|
||||||
"resource_type": r.resource_type or '',
|
|
||||||
"citation": r.citation or '',
|
|
||||||
"is_required": r.is_required,
|
|
||||||
"is_available": r.is_available,
|
|
||||||
"status": r.status or 'needed',
|
|
||||||
"ai_usage_notes": r.ai_usage_notes or '',
|
|
||||||
"extracted_content": json.loads(r.extracted_content_json or '{}'),
|
|
||||||
})
|
|
||||||
return {"ok": True, "count": len(items), "resources": items}
|
|
||||||
except Exception as exc:
|
|
||||||
_logger.exception("resources.fetch failed")
|
|
||||||
return {"error": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
@register("resources.save")
|
|
||||||
def _save_resource(env, plan_id: int, name: str = "", resource_type: str = "textbook",
|
|
||||||
citation: str = "", ai_usage_notes: str = "", is_required: bool = True,
|
|
||||||
extracted_content: dict | None = None, **_: Any) -> dict:
|
|
||||||
"""Save a resource dependency for a course plan (used by AI agents)."""
|
|
||||||
try:
|
|
||||||
ResourceDep = env["encoach.course.plan.resource.dep"].sudo()
|
|
||||||
if not ResourceDep:
|
|
||||||
return {"error": "resource_dep_model_missing"}
|
|
||||||
|
|
||||||
rec = ResourceDep.create({
|
|
||||||
"plan_id": int(plan_id),
|
|
||||||
"name": name,
|
|
||||||
"resource_type": resource_type,
|
|
||||||
"citation": citation,
|
|
||||||
"ai_usage_notes": ai_usage_notes,
|
|
||||||
"is_required": is_required,
|
|
||||||
"extracted_content_json": json.dumps(extracted_content or {}, ensure_ascii=False),
|
|
||||||
"status": 'available' if extracted_content else 'needed',
|
|
||||||
})
|
|
||||||
return {"ok": True, "resource_id": rec.id, "name": name}
|
|
||||||
except Exception as exc:
|
|
||||||
_logger.exception("resources.save failed")
|
|
||||||
return {"error": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Rich Media Generation (Images, Audio, Video) ---
|
|
||||||
|
|
||||||
@register("media.generate_image")
|
@register("media.generate_image")
|
||||||
def _generate_image(env, prompt: str = "", material_id: int | None = None,
|
def _media_generate_image(env, material_id: int, prompt: str = "",
|
||||||
style: str = "educational", **_: Any) -> dict:
|
size: str = "1024x1024",
|
||||||
"""Generate an educational image using DALL-E or similar.
|
style: str = "natural",
|
||||||
|
quality: str = "standard", **_: Any) -> dict:
|
||||||
Saves the generated image as an Odoo attachment and returns the URL.
|
Material = env["encoach.course.plan.material"].sudo() \
|
||||||
"""
|
if "encoach.course.plan.material" in env else None
|
||||||
try:
|
if Material is None:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
return {"error": "course_plan_material_model_missing"}
|
||||||
svc = OpenAIService(env)
|
rec = Material.browse(int(material_id))
|
||||||
|
if not rec.exists():
|
||||||
# Enhance prompt for educational context
|
return {"error": "material_not_found"}
|
||||||
educational_prompt = f"""Create an educational illustration for language learning.
|
from odoo.addons.encoach_ai_course.services.media_service import MediaService
|
||||||
Style: {style} (clear, appropriate for {env.get('cefr_level', 'A2')} level)
|
svc = MediaService(env)
|
||||||
Content: {prompt}
|
media = svc.generate_image(
|
||||||
Requirements: Simple visuals, clear labels if text appears, culturally neutral,
|
rec, custom_prompt=prompt or None,
|
||||||
suitable for classroom projection or digital learning."""
|
size=size or "1024x1024",
|
||||||
|
style=style or "natural",
|
||||||
# Call image generation (using OpenAI DALL-E if available)
|
quality=quality or "standard",
|
||||||
# Note: OpenAIService would need image generation support added
|
)
|
||||||
# For now, return structured response for the AI to handle
|
return {
|
||||||
|
"media_id": media.id,
|
||||||
return {
|
"status": media.status,
|
||||||
"ok": True,
|
"download_url": media.download_url,
|
||||||
"generation_type": "image",
|
"error": media.error or None,
|
||||||
"prompt_used": educational_prompt,
|
}
|
||||||
"style": style,
|
|
||||||
"note": "Image generation requires DALL-E or Stable Diffusion integration. "
|
|
||||||
"Store the generated image URL in material.media_asset_url",
|
|
||||||
"suggested_dimensions": "1024x1024",
|
|
||||||
"material_id": material_id,
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
_logger.exception("media.generate_image failed")
|
|
||||||
return {"error": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
@register("media.generate_audio")
|
@register("media.compose_video")
|
||||||
def _generate_audio(env, text: str = "", voice: str = "", material_id: int | None = None,
|
def _media_compose_video(env, material_id: int, **_: Any) -> dict:
|
||||||
purpose: str = "listening_exercise", **_: Any) -> dict:
|
Material = env["encoach.course.plan.material"].sudo() \
|
||||||
"""Generate audio using TTS (ElevenLabs, AWS Polly, etc.).
|
if "encoach.course.plan.material" in env else None
|
||||||
|
if Material is None:
|
||||||
Suitable for listening scripts, pronunciation examples, etc.
|
return {"error": "course_plan_material_model_missing"}
|
||||||
"""
|
rec = Material.browse(int(material_id))
|
||||||
try:
|
if not rec.exists():
|
||||||
# Try ElevenLabs first (if configured)
|
return {"error": "material_not_found"}
|
||||||
try:
|
from odoo.addons.encoach_ai_course.services.media_service import MediaService
|
||||||
from odoo.addons.encoach_ai.services.elevenlabs_service import ElevenLabsService
|
svc = MediaService(env)
|
||||||
svc = ElevenLabsService(env)
|
media = svc.compose_video(rec)
|
||||||
# Would call: svc.text_to_speech(text, voice_id=voice)
|
return {
|
||||||
return {
|
"media_id": media.id,
|
||||||
"ok": True,
|
"status": media.status,
|
||||||
"generation_type": "audio",
|
"download_url": media.download_url,
|
||||||
"service": "elevenlabs",
|
"error": media.error or None,
|
||||||
"text_sample": text[:100] + "..." if len(text) > 100 else text,
|
}
|
||||||
"voice": voice or "default",
|
|
||||||
"purpose": purpose,
|
|
||||||
"note": "Audio generation configured. Store URL in material.media_asset_url",
|
|
||||||
"material_id": material_id,
|
|
||||||
}
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fall back to AWS Polly
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.polly_service import PollyService
|
|
||||||
svc = PollyService(env)
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"generation_type": "audio",
|
|
||||||
"service": "aws_polly",
|
|
||||||
"text_sample": text[:100] + "..." if len(text) > 100 else text,
|
|
||||||
"voice": voice or "Joanna",
|
|
||||||
"purpose": purpose,
|
|
||||||
"note": "AWS Polly audio generation. Store URL in material.media_asset_url",
|
|
||||||
"material_id": material_id,
|
|
||||||
}
|
|
||||||
except ImportError:
|
|
||||||
return {"ok": False, "error": "No TTS service available (ElevenLabs or Polly required)"}
|
|
||||||
except Exception as exc:
|
|
||||||
_logger.exception("media.generate_audio failed")
|
|
||||||
return {"error": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
@register("media.suggest_visuals")
|
|
||||||
def _suggest_visuals(env, content_description: str = "", material_type: str = "",
|
|
||||||
target_audience: str = "", **_: Any) -> dict:
|
|
||||||
"""AI tool to suggest what visuals would enhance a teaching material.
|
|
||||||
|
|
||||||
Returns suggestions for images, diagrams, or videos that should be
|
|
||||||
generated to support the content.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
svc = OpenAIService(env)
|
|
||||||
|
|
||||||
prompt = f"""For this teaching material, suggest 3-5 visual aids that would enhance learning:
|
|
||||||
|
|
||||||
Material Type: {material_type}
|
|
||||||
Target Audience: {target_audience or 'A2 level adult learners'}
|
|
||||||
Content: {content_description[:2000]}
|
|
||||||
|
|
||||||
Return JSON:
|
|
||||||
{{
|
|
||||||
"visuals": [
|
|
||||||
{{
|
|
||||||
"type": "image|diagram|chart|illustration",
|
|
||||||
"description": "What to show",
|
|
||||||
"prompt_for_ai": "Detailed prompt for image generation",
|
|
||||||
"learning_purpose": "Why this visual helps",
|
|
||||||
"complexity": "low|medium|high"
|
|
||||||
}}
|
|
||||||
]
|
|
||||||
}}"""
|
|
||||||
|
|
||||||
result = svc.chat_json([
|
|
||||||
{"role": "system", "content": "You are an educational design AI. Suggest effective visual aids for language learning materials."},
|
|
||||||
{"role": "user", "content": prompt}
|
|
||||||
], temperature=0.6, max_tokens=2000)
|
|
||||||
|
|
||||||
if result and 'visuals' in result:
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"suggestions_count": len(result.get('visuals', [])),
|
|
||||||
"visuals": result.get('visuals', []),
|
|
||||||
}
|
|
||||||
return {"ok": False, "error": "Could not generate suggestions", "raw": result}
|
|
||||||
except Exception as exc:
|
|
||||||
_logger.exception("media.suggest_visuals failed")
|
|
||||||
return {"error": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Assignment & Delivery Tracking ---
|
|
||||||
|
|
||||||
@register("assignment.create")
|
|
||||||
def _create_assignment(env, plan_id: int, assignment_type: str = "class",
|
|
||||||
batch_id: int | None = None, student_id: int | None = None,
|
|
||||||
start_date: str = "", delivery_mode: str = "sequential", **_: Any) -> dict:
|
|
||||||
"""Create a course plan assignment to deliver to students/classes.
|
|
||||||
|
|
||||||
Also creates tracking rows for each deliverable.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
Assignment = env["encoach.course.plan.assignment"].sudo()
|
|
||||||
Deliverable = env["encoach.course.plan.deliverable"].sudo()
|
|
||||||
AssignmentDeliverable = env["encoach.course.plan.assignment.deliverable"].sudo()
|
|
||||||
|
|
||||||
if not Assignment:
|
|
||||||
return {"error": "assignment_model_missing"}
|
|
||||||
|
|
||||||
# Create assignment
|
|
||||||
vals = {
|
|
||||||
"plan_id": int(plan_id),
|
|
||||||
"assignment_type": assignment_type,
|
|
||||||
"delivery_mode": delivery_mode,
|
|
||||||
"status": "scheduled",
|
|
||||||
}
|
|
||||||
if batch_id:
|
|
||||||
vals["batch_id"] = int(batch_id)
|
|
||||||
if student_id:
|
|
||||||
vals["student_id"] = int(student_id)
|
|
||||||
if start_date:
|
|
||||||
vals["start_date"] = start_date
|
|
||||||
|
|
||||||
assignment = Assignment.create(vals)
|
|
||||||
|
|
||||||
# Create deliverable tracking rows
|
|
||||||
deliverables = Deliverable.search([("plan_id", "=", int(plan_id))])
|
|
||||||
created_tracking = 0
|
|
||||||
for d in deliverables:
|
|
||||||
try:
|
|
||||||
AssignmentDeliverable.create({
|
|
||||||
"assignment_id": assignment.id,
|
|
||||||
"deliverable_id": d.id,
|
|
||||||
"status": "not_started",
|
|
||||||
})
|
|
||||||
created_tracking += 1
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"assignment_id": assignment.id,
|
|
||||||
"assignment_name": assignment.name,
|
|
||||||
"deliverables_tracking_created": created_tracking,
|
|
||||||
"note": "Assignment created. Students can now access the course plan.",
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
_logger.exception("assignment.create failed")
|
|
||||||
return {"error": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
@register("assignment.progress")
|
|
||||||
def _get_assignment_progress(env, assignment_id: int, **_: Any) -> dict:
|
|
||||||
"""Get progress summary for a course plan assignment."""
|
|
||||||
try:
|
|
||||||
Assignment = env["encoach.course.plan.assignment"].sudo()
|
|
||||||
AssignmentDeliverable = env["encoach.course.plan.assignment.deliverable"].sudo()
|
|
||||||
|
|
||||||
assignment = Assignment.browse(int(assignment_id))
|
|
||||||
if not assignment.exists():
|
|
||||||
return {"error": "assignment_not_found"}
|
|
||||||
|
|
||||||
# Count deliverable statuses
|
|
||||||
tracking = AssignmentDeliverable.search([("assignment_id", "=", int(assignment_id))])
|
|
||||||
status_counts = {}
|
|
||||||
for t in tracking:
|
|
||||||
status_counts[t.status] = status_counts.get(t.status, 0) + 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"assignment_id": assignment_id,
|
|
||||||
"assignment_status": assignment.status,
|
|
||||||
"current_week": assignment.current_week,
|
|
||||||
"progress_percent": assignment.progress_percent,
|
|
||||||
"deliverables_total": len(tracking),
|
|
||||||
"deliverables_by_status": status_counts,
|
|
||||||
"start_date": str(assignment.start_date) if assignment.start_date else None,
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
_logger.exception("assignment.progress failed")
|
|
||||||
return {"error": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
@register("assignment.update_deliverable")
|
|
||||||
def _update_deliverable_status(env, assignment_deliverable_id: int, status: str,
|
|
||||||
score: float | None = None, notes: str = "", **_: Any) -> dict:
|
|
||||||
"""Update the completion status of a deliverable for an assignment."""
|
|
||||||
try:
|
|
||||||
AssignmentDeliverable = env["encoach.course.plan.assignment.deliverable"].sudo()
|
|
||||||
rec = AssignmentDeliverable.browse(int(assignment_deliverable_id))
|
|
||||||
if not rec.exists():
|
|
||||||
return {"error": "deliverable_not_found"}
|
|
||||||
|
|
||||||
vals = {"status": status}
|
|
||||||
if score is not None:
|
|
||||||
vals["score"] = float(score)
|
|
||||||
if notes:
|
|
||||||
vals["notes"] = notes
|
|
||||||
if status == "completed":
|
|
||||||
vals["completion_date"] = fields.Datetime.now()
|
|
||||||
vals["completed_by_id"] = env.uid
|
|
||||||
|
|
||||||
rec.write(vals)
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"deliverable_id": int(assignment_deliverable_id),
|
|
||||||
"new_status": status,
|
|
||||||
"assignment_id": rec.assignment_id.id,
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
_logger.exception("assignment.update_deliverable failed")
|
|
||||||
return {"error": str(exc)}
|
|
||||||
|
|||||||
@@ -213,6 +213,60 @@ class OpenAIService:
|
|||||||
"""Use the fast/cheap model for classification, tagging, simple tasks."""
|
"""Use the fast/cheap model for classification, tagging, simple tasks."""
|
||||||
return self.chat(messages, model=self.fast_model, **kwargs)
|
return self.chat(messages, model=self.fast_model, **kwargs)
|
||||||
|
|
||||||
|
def generate_image(self, prompt, *, size="1024x1024", style="natural",
|
||||||
|
quality="standard", model=None):
|
||||||
|
"""Generate an image with DALL-E 3 and return raw PNG bytes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict {"image": bytes, "revised_prompt": str, "model": str,
|
||||||
|
"size": str, "style": str}.
|
||||||
|
|
||||||
|
Raises ``RuntimeError`` if OpenAI is not configured. Network /
|
||||||
|
moderation failures bubble up as the SDK's exceptions; callers
|
||||||
|
should catch and record them.
|
||||||
|
"""
|
||||||
|
self._check_enabled()
|
||||||
|
if not self.client:
|
||||||
|
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
|
||||||
|
image_model = model or self._get_param(
|
||||||
|
"encoach_ai.openai_image_model", "dall-e-3",
|
||||||
|
)
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
resp = self.client.images.generate(
|
||||||
|
model=image_model,
|
||||||
|
prompt=prompt or "",
|
||||||
|
n=1,
|
||||||
|
size=size,
|
||||||
|
style=style,
|
||||||
|
quality=quality,
|
||||||
|
response_format="b64_json",
|
||||||
|
timeout=self.request_timeout,
|
||||||
|
)
|
||||||
|
data = resp.data[0]
|
||||||
|
import base64 as _b64
|
||||||
|
img_bytes = _b64.b64decode(data.b64_json)
|
||||||
|
self._log(
|
||||||
|
"generate_image", image_model, None,
|
||||||
|
int((time.time() - t0) * 1000),
|
||||||
|
inp=(prompt or "")[:500],
|
||||||
|
out=f"{len(img_bytes)}B {size} {style}",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"image": img_bytes,
|
||||||
|
"revised_prompt": getattr(data, "revised_prompt", "") or "",
|
||||||
|
"model": image_model,
|
||||||
|
"size": size,
|
||||||
|
"style": style,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
self._log(
|
||||||
|
"generate_image", image_model, None,
|
||||||
|
int((time.time() - t0) * 1000),
|
||||||
|
status="error", error=str(exc),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
def grade_writing(self, rubric, task_text, response_text):
|
def grade_writing(self, rubric, task_text, response_text):
|
||||||
"""Grade a writing response using GPT with a rubric."""
|
"""Grade a writing response using GPT with a rubric."""
|
||||||
messages = [
|
messages = [
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ endpoints. Every route is JWT-guarded via the shared ``@jwt_required``
|
|||||||
decorator and returns JSON.
|
decorator and returns JSON.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from odoo import http, fields
|
from odoo import http
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
from odoo.addons.encoach_api.controllers.base import (
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
jwt_required,
|
jwt_required,
|
||||||
@@ -21,6 +21,10 @@ from odoo.addons.encoach_api.controllers.base import (
|
|||||||
from odoo.addons.encoach_ai_course.services.course_plan_pipeline import (
|
from odoo.addons.encoach_ai_course.services.course_plan_pipeline import (
|
||||||
CoursePlanPipeline,
|
CoursePlanPipeline,
|
||||||
)
|
)
|
||||||
|
from odoo.addons.encoach_ai_course.services.deliverables import (
|
||||||
|
compute_deliverables,
|
||||||
|
)
|
||||||
|
from odoo.addons.encoach_ai_course.services.media_service import MediaService
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -172,313 +176,421 @@ class CoursePlanController(http.Controller):
|
|||||||
return _error_response(str(exc), 500)
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
# ==================================================================
|
# ==================================================================
|
||||||
# NEW: Deliverable Detection & Management
|
# PHASE A — Reference sources (RAG grounding)
|
||||||
# ==================================================================
|
# ==================================================================
|
||||||
|
|
||||||
# POST /api/ai/course-plan/<id>/deliverables/detect
|
@http.route('/api/ai/course-plan/<int:plan_id>/sources',
|
||||||
@http.route('/api/ai/course-plan/<int:plan_id>/deliverables/detect',
|
type='http', auth='none', methods=['GET'], csrf=False)
|
||||||
type='http', auth='none', methods=['POST'], csrf=False)
|
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def detect_deliverables(self, plan_id, **kw):
|
def list_sources(self, plan_id, **kw):
|
||||||
"""Parse course outline text and extract structured deliverables."""
|
|
||||||
try:
|
try:
|
||||||
body = _get_json_body()
|
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||||
outline_text = body.get('outline_text', '').strip()
|
if not plan.exists():
|
||||||
if not outline_text:
|
return _error_response('Plan not found', 404)
|
||||||
return _error_response('outline_text is required', 400)
|
return _json_response({
|
||||||
|
'items': [s.to_api_dict() for s in plan.source_ids],
|
||||||
pipeline = CoursePlanPipeline(
|
'count': len(plan.source_ids),
|
||||||
request.env, language=_request_language(),
|
})
|
||||||
)
|
|
||||||
result = pipeline.generate_deliverables_from_outline(plan_id, outline_text)
|
|
||||||
return _json_response(result)
|
|
||||||
except ValueError as exc:
|
|
||||||
return _error_response(str(exc), 404)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.exception('course-plan.detect_deliverables failed')
|
_logger.exception('course-plan.list_sources failed')
|
||||||
return _error_response(str(exc), 500)
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
# GET /api/ai/course-plan/<id>/deliverables
|
@http.route('/api/ai/course-plan/<int:plan_id>/sources',
|
||||||
|
type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def create_source(self, plan_id, **kw):
|
||||||
|
try:
|
||||||
|
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||||
|
if not plan.exists():
|
||||||
|
return _error_response('Plan not found', 404)
|
||||||
|
|
||||||
|
ct = request.httprequest.content_type or ''
|
||||||
|
files = request.httprequest.files
|
||||||
|
uploaded = files.get('file')
|
||||||
|
if 'application/json' in ct:
|
||||||
|
body = _get_json_body() or {}
|
||||||
|
else:
|
||||||
|
body = {**kw}
|
||||||
|
|
||||||
|
kind = (body.get('kind') or '').strip() or (
|
||||||
|
'file' if uploaded else
|
||||||
|
('url' if (body.get('url') or '').strip() else 'text')
|
||||||
|
)
|
||||||
|
name = (body.get('name') or '').strip()
|
||||||
|
auto_index = body.get('auto_index')
|
||||||
|
if isinstance(auto_index, str):
|
||||||
|
auto_index = auto_index.lower() not in ('0', 'false', 'no', 'off')
|
||||||
|
elif auto_index is None:
|
||||||
|
auto_index = True
|
||||||
|
vals = {
|
||||||
|
'plan_id': plan.id,
|
||||||
|
'kind': kind,
|
||||||
|
'auto_index': bool(auto_index),
|
||||||
|
}
|
||||||
|
if kind == 'file':
|
||||||
|
if not uploaded:
|
||||||
|
return _error_response('No file uploaded', 400)
|
||||||
|
payload = uploaded.read()
|
||||||
|
vals['file'] = base64.b64encode(payload)
|
||||||
|
vals['file_name'] = uploaded.filename or 'source'
|
||||||
|
vals['mime_type'] = uploaded.mimetype or ''
|
||||||
|
vals['name'] = name or uploaded.filename or 'source'
|
||||||
|
elif kind == 'url':
|
||||||
|
url = (body.get('url') or '').strip()
|
||||||
|
if not url:
|
||||||
|
return _error_response('URL is required', 400)
|
||||||
|
vals['url'] = url
|
||||||
|
vals['name'] = name or url
|
||||||
|
else:
|
||||||
|
text = (body.get('inline_text') or body.get('text') or '').strip()
|
||||||
|
if not text:
|
||||||
|
return _error_response('Inline text is required', 400)
|
||||||
|
vals['inline_text'] = text
|
||||||
|
vals['name'] = name or 'Inline text'
|
||||||
|
|
||||||
|
rec = request.env['encoach.course.plan.source'].sudo().create(vals)
|
||||||
|
return _json_response({'data': rec.to_api_dict()})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('course-plan.create_source failed')
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
|
@http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>/index',
|
||||||
|
type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def reindex_source(self, plan_id, source_id, **kw):
|
||||||
|
try:
|
||||||
|
rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id))
|
||||||
|
if not rec.exists() or rec.plan_id.id != int(plan_id):
|
||||||
|
return _error_response('Source not found', 404)
|
||||||
|
rec.action_index()
|
||||||
|
return _json_response({'data': rec.to_api_dict()})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('course-plan.reindex_source failed')
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
|
@http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>',
|
||||||
|
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def delete_source(self, plan_id, source_id, **kw):
|
||||||
|
try:
|
||||||
|
rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id))
|
||||||
|
if not rec.exists() or rec.plan_id.id != int(plan_id):
|
||||||
|
return _error_response('Source not found', 404)
|
||||||
|
rec.unlink()
|
||||||
|
return _json_response({'success': True})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('course-plan.delete_source failed')
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
|
# ==================================================================
|
||||||
|
# PHASE B — Deliverables preview / progress
|
||||||
|
# ==================================================================
|
||||||
|
|
||||||
@http.route('/api/ai/course-plan/<int:plan_id>/deliverables',
|
@http.route('/api/ai/course-plan/<int:plan_id>/deliverables',
|
||||||
type='http', auth='none', methods=['GET'], csrf=False)
|
type='http', auth='none', methods=['GET'], csrf=False)
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def list_deliverables(self, plan_id, **kw):
|
def get_deliverables(self, plan_id, **kw):
|
||||||
"""List deliverables for a course plan."""
|
|
||||||
try:
|
try:
|
||||||
params = request.httprequest.args
|
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||||
domain = [('plan_id', '=', int(plan_id))]
|
if not plan.exists():
|
||||||
week = params.get('week')
|
return _error_response('Plan not found', 404)
|
||||||
if week:
|
return _json_response(compute_deliverables(plan))
|
||||||
domain.append(('week_number', '=', int(week)))
|
|
||||||
skill = params.get('skill')
|
|
||||||
if skill:
|
|
||||||
domain.append(('skill', '=', skill))
|
|
||||||
|
|
||||||
Deliverable = request.env['encoach.course.plan.deliverable'].sudo()
|
|
||||||
records = Deliverable.search(domain, order='week_number, code')
|
|
||||||
return _json_response({
|
|
||||||
'items': [d.to_api_dict() for d in records],
|
|
||||||
'count': len(records),
|
|
||||||
})
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.exception('course-plan.list_deliverables failed')
|
_logger.exception('course-plan.deliverables failed')
|
||||||
return _error_response(str(exc), 500)
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
# PUT /api/ai/course-plan/deliverables/<id>
|
# ==================================================================
|
||||||
@http.route('/api/ai/course-plan/deliverables/<int:deliverable_id>',
|
# PHASE C — Multimedia generation per material
|
||||||
type='http', auth='none', methods=['PUT'], csrf=False)
|
# ==================================================================
|
||||||
|
|
||||||
|
def _resolve_material(self, material_id):
|
||||||
|
rec = request.env['encoach.course.plan.material'].sudo().browse(int(material_id))
|
||||||
|
if not rec.exists():
|
||||||
|
return None
|
||||||
|
return rec
|
||||||
|
|
||||||
|
@http.route('/api/ai/course-plan/material/<int:material_id>/media/audio',
|
||||||
|
type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def update_deliverable(self, deliverable_id, **kw):
|
def gen_audio(self, material_id, **kw):
|
||||||
"""Update deliverable status or details."""
|
|
||||||
try:
|
try:
|
||||||
body = _get_json_body()
|
material = self._resolve_material(material_id)
|
||||||
Deliverable = request.env['encoach.course.plan.deliverable'].sudo()
|
if not material:
|
||||||
rec = Deliverable.browse(int(deliverable_id))
|
return _error_response('Material not found', 404)
|
||||||
if not rec.exists():
|
body = _get_json_body() or {}
|
||||||
return _error_response('Deliverable not found', 404)
|
svc = MediaService(request.env)
|
||||||
|
media = svc.synthesize_audio(
|
||||||
updatable = {}
|
material,
|
||||||
if 'status' in body:
|
voice=body.get('voice'),
|
||||||
updatable['status'] = body['status']
|
language=body.get('language') or 'en-GB',
|
||||||
if 'target_date' in body:
|
gender=body.get('gender') or 'female',
|
||||||
updatable['target_date'] = body['target_date']
|
provider=body.get('provider') or 'polly',
|
||||||
if 'description' in body:
|
)
|
||||||
updatable['description'] = body['description']
|
return _json_response({'data': media.to_api_dict()})
|
||||||
|
|
||||||
if updatable:
|
|
||||||
rec.write(updatable)
|
|
||||||
return _json_response({'data': rec.to_api_dict()})
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.exception('course-plan.update_deliverable failed')
|
_logger.exception('course-plan.gen_audio failed')
|
||||||
return _error_response(str(exc), 500)
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
# ==================================================================
|
@http.route('/api/ai/course-plan/material/<int:material_id>/media/image',
|
||||||
# NEW: Resource Dependencies
|
type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
# ==================================================================
|
@jwt_required
|
||||||
|
def gen_image(self, material_id, **kw):
|
||||||
|
try:
|
||||||
|
material = self._resolve_material(material_id)
|
||||||
|
if not material:
|
||||||
|
return _error_response('Material not found', 404)
|
||||||
|
body = _get_json_body() or {}
|
||||||
|
svc = MediaService(request.env)
|
||||||
|
media = svc.generate_image(
|
||||||
|
material,
|
||||||
|
custom_prompt=body.get('prompt'),
|
||||||
|
size=body.get('size') or '1024x1024',
|
||||||
|
style=body.get('style') or 'natural',
|
||||||
|
quality=body.get('quality') or 'standard',
|
||||||
|
)
|
||||||
|
return _json_response({'data': media.to_api_dict()})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('course-plan.gen_image failed')
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
# GET /api/ai/course-plan/<id>/resources
|
@http.route('/api/ai/course-plan/material/<int:material_id>/media/video',
|
||||||
@http.route('/api/ai/course-plan/<int:plan_id>/resources',
|
type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def gen_video(self, material_id, **kw):
|
||||||
|
try:
|
||||||
|
material = self._resolve_material(material_id)
|
||||||
|
if not material:
|
||||||
|
return _error_response('Material not found', 404)
|
||||||
|
svc = MediaService(request.env)
|
||||||
|
media = svc.compose_video(material)
|
||||||
|
return _json_response({'data': media.to_api_dict()})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('course-plan.gen_video failed')
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
|
@http.route('/api/ai/course-plan/material/<int:material_id>/media',
|
||||||
type='http', auth='none', methods=['GET'], csrf=False)
|
type='http', auth='none', methods=['GET'], csrf=False)
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def list_resources(self, plan_id, **kw):
|
def list_material_media(self, material_id, **kw):
|
||||||
"""List resource dependencies for a course plan."""
|
|
||||||
try:
|
try:
|
||||||
params = request.httprequest.args
|
material = self._resolve_material(material_id)
|
||||||
domain = [('plan_id', '=', int(plan_id))]
|
if not material:
|
||||||
resource_type = params.get('type')
|
return _error_response('Material not found', 404)
|
||||||
if resource_type:
|
|
||||||
domain.append(('resource_type', '=', resource_type))
|
|
||||||
|
|
||||||
ResourceDep = request.env['encoach.course.plan.resource.dep'].sudo()
|
|
||||||
records = ResourceDep.search(domain)
|
|
||||||
return _json_response({
|
return _json_response({
|
||||||
'items': [r.to_api_dict() for r in records],
|
'items': [m.to_api_dict() for m in material.media_ids],
|
||||||
'count': len(records),
|
'count': len(material.media_ids),
|
||||||
})
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.exception('course-plan.list_resources failed')
|
_logger.exception('course-plan.list_material_media failed')
|
||||||
return _error_response(str(exc), 500)
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
# POST /api/ai/course-plan/<id>/resources
|
@http.route('/api/ai/course-plan/media/<int:media_id>',
|
||||||
@http.route('/api/ai/course-plan/<int:plan_id>/resources',
|
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||||
type='http', auth='none', methods=['POST'], csrf=False)
|
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def add_resource(self, plan_id, **kw):
|
def delete_media(self, media_id, **kw):
|
||||||
"""Add a resource dependency to a course plan."""
|
|
||||||
try:
|
try:
|
||||||
body = _get_json_body()
|
rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id))
|
||||||
if not body.get('name'):
|
if not rec.exists():
|
||||||
return _error_response('name is required', 400)
|
return _error_response('Media not found', 404)
|
||||||
|
if rec.attachment_id:
|
||||||
ResourceDep = request.env['encoach.course.plan.resource.dep'].sudo()
|
rec.attachment_id.unlink()
|
||||||
rec = ResourceDep.create({
|
rec.unlink()
|
||||||
'plan_id': int(plan_id),
|
return _json_response({'success': True})
|
||||||
'name': body['name'],
|
|
||||||
'resource_type': body.get('resource_type', 'textbook'),
|
|
||||||
'citation': body.get('citation', ''),
|
|
||||||
'url': body.get('url', ''),
|
|
||||||
'ai_usage_notes': body.get('ai_usage_notes', ''),
|
|
||||||
'is_required': body.get('is_required', True),
|
|
||||||
'extracted_content_json': json.dumps(body.get('extracted_content', {})),
|
|
||||||
})
|
|
||||||
return _json_response({'data': rec.to_api_dict()})
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.exception('course-plan.add_resource failed')
|
_logger.exception('course-plan.delete_media failed')
|
||||||
return _json_response(str(exc), 500)
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
# ==================================================================
|
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/media',
|
||||||
# NEW: Rich Media Generation
|
|
||||||
# ==================================================================
|
|
||||||
|
|
||||||
# POST /api/ai/course-plan/materials/<id>/media/suggest
|
|
||||||
@http.route('/api/ai/course-plan/materials/<int:material_id>/media/suggest',
|
|
||||||
type='http', auth='none', methods=['POST'], csrf=False)
|
type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def suggest_media(self, material_id, **kw):
|
def gen_week_media(self, plan_id, week_number, **kw):
|
||||||
"""Get AI suggestions for media that would enhance this material."""
|
"""Generate every applicable modality for every material in a week.
|
||||||
|
|
||||||
|
Body shape: ``{ kinds: ['audio', 'image', 'video'] }``.
|
||||||
|
Default is ``['audio', 'image']`` — video is opt-in because it
|
||||||
|
depends on ffmpeg + the audio + image steps and is slower.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
pipeline = CoursePlanPipeline(
|
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||||
request.env, language=_request_language(),
|
if not plan.exists():
|
||||||
|
return _error_response('Plan not found', 404)
|
||||||
|
week = plan.week_ids.filtered(
|
||||||
|
lambda w: w.week_number == int(week_number),
|
||||||
)
|
)
|
||||||
result = pipeline.suggest_media_for_material(material_id)
|
if not week:
|
||||||
if 'error' in result:
|
return _error_response('Week not found', 404)
|
||||||
return _error_response(result['error'], 400)
|
week = week[0]
|
||||||
return _json_response(result)
|
body = _get_json_body() or {}
|
||||||
except ValueError as exc:
|
kinds = body.get('kinds') or ['audio', 'image']
|
||||||
return _error_response(str(exc), 404)
|
kinds = [str(k).lower() for k in kinds]
|
||||||
except Exception as exc:
|
|
||||||
_logger.exception('course-plan.suggest_media failed')
|
|
||||||
return _error_response(str(exc), 500)
|
|
||||||
|
|
||||||
# POST /api/ai/course-plan/materials/<id>/media/generate
|
svc = MediaService(request.env)
|
||||||
@http.route('/api/ai/course-plan/materials/<int:material_id>/media/generate',
|
results = []
|
||||||
type='http', auth='none', methods=['POST'], csrf=False)
|
for material in week.material_ids:
|
||||||
@jwt_required
|
if 'audio' in kinds and material.material_type in (
|
||||||
def generate_media(self, material_id, **kw):
|
'listening_script', 'speaking_prompt',
|
||||||
"""Generate media (image, audio) for a teaching material."""
|
):
|
||||||
try:
|
results.append(svc.synthesize_audio(material).to_api_dict())
|
||||||
body = _get_json_body()
|
if 'image' in kinds and material.material_type in (
|
||||||
media_type = body.get('media_type', 'image')
|
'reading_text', 'listening_script', 'vocabulary_list',
|
||||||
|
):
|
||||||
pipeline = CoursePlanPipeline(
|
try:
|
||||||
request.env, language=_request_language(),
|
results.append(svc.generate_image(material).to_api_dict())
|
||||||
)
|
except Exception as exc:
|
||||||
result = pipeline.generate_media_for_material(material_id, media_type)
|
results.append({'error': str(exc), 'material_id': material.id})
|
||||||
if 'error' in result:
|
if 'video' in kinds and material.material_type == 'listening_script':
|
||||||
return _error_response(result['error'], 400)
|
results.append(svc.compose_video(material).to_api_dict())
|
||||||
return _json_response(result)
|
return _json_response({'items': results, 'count': len(results)})
|
||||||
except ValueError as exc:
|
|
||||||
return _error_response(str(exc), 404)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.exception('course-plan.generate_media failed')
|
_logger.exception('course-plan.gen_week_media failed')
|
||||||
return _error_response(str(exc), 500)
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
# ==================================================================
|
# ==================================================================
|
||||||
# NEW: Course Plan Assignment to Classes/Students
|
# PHASE D — Plan assignments
|
||||||
# ==================================================================
|
# ==================================================================
|
||||||
|
|
||||||
# POST /api/ai/course-plan/<id>/assignments
|
|
||||||
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
|
|
||||||
type='http', auth='none', methods=['POST'], csrf=False)
|
|
||||||
@jwt_required
|
|
||||||
def create_assignment(self, plan_id, **kw):
|
|
||||||
"""Assign a course plan to a class or student."""
|
|
||||||
try:
|
|
||||||
body = _get_json_body()
|
|
||||||
Assignment = request.env['encoach.course.plan.assignment'].sudo()
|
|
||||||
|
|
||||||
vals = {
|
|
||||||
'plan_id': int(plan_id),
|
|
||||||
'assignment_type': body.get('assignment_type', 'class'),
|
|
||||||
'delivery_mode': body.get('delivery_mode', 'sequential'),
|
|
||||||
'status': 'scheduled',
|
|
||||||
}
|
|
||||||
if body.get('batch_id'):
|
|
||||||
vals['batch_id'] = int(body['batch_id'])
|
|
||||||
if body.get('student_id'):
|
|
||||||
vals['student_id'] = int(body['student_id'])
|
|
||||||
if body.get('start_date'):
|
|
||||||
vals['start_date'] = body['start_date']
|
|
||||||
|
|
||||||
assignment = Assignment.create(vals)
|
|
||||||
|
|
||||||
# Create deliverable tracking rows
|
|
||||||
Deliverable = request.env['encoach.course.plan.deliverable'].sudo()
|
|
||||||
AssignmentDeliverable = request.env['encoach.course.plan.assignment.deliverable'].sudo()
|
|
||||||
deliverables = Deliverable.search([('plan_id', '=', int(plan_id))])
|
|
||||||
for d in deliverables:
|
|
||||||
AssignmentDeliverable.create({
|
|
||||||
'assignment_id': assignment.id,
|
|
||||||
'deliverable_id': d.id,
|
|
||||||
'status': 'not_started',
|
|
||||||
})
|
|
||||||
|
|
||||||
return _json_response({
|
|
||||||
'data': assignment.to_api_dict(),
|
|
||||||
'deliverables_tracked': len(deliverables),
|
|
||||||
})
|
|
||||||
except Exception as exc:
|
|
||||||
_logger.exception('course-plan.create_assignment failed')
|
|
||||||
return _error_response(str(exc), 500)
|
|
||||||
|
|
||||||
# GET /api/ai/course-plan/<id>/assignments
|
|
||||||
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
|
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
|
||||||
type='http', auth='none', methods=['GET'], csrf=False)
|
type='http', auth='none', methods=['GET'], csrf=False)
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def list_assignments(self, plan_id, **kw):
|
def list_assignments(self, plan_id, **kw):
|
||||||
"""List assignments for a course plan."""
|
|
||||||
try:
|
try:
|
||||||
Assignment = request.env['encoach.course.plan.assignment'].sudo()
|
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||||
records = Assignment.search([('plan_id', '=', int(plan_id))])
|
if not plan.exists():
|
||||||
|
return _error_response('Plan not found', 404)
|
||||||
return _json_response({
|
return _json_response({
|
||||||
'items': [a.to_api_dict() for a in records],
|
'items': [a.to_api_dict() for a in plan.assignment_ids],
|
||||||
'count': len(records),
|
'count': len(plan.assignment_ids),
|
||||||
})
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.exception('course-plan.list_assignments failed')
|
_logger.exception('course-plan.list_assignments failed')
|
||||||
return _error_response(str(exc), 500)
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
# GET /api/ai/course-plan/assignments/<id>
|
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
|
||||||
@http.route('/api/ai/course-plan/assignments/<int:assignment_id>',
|
type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
type='http', auth='none', methods=['GET'], csrf=False)
|
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def get_assignment(self, assignment_id, **kw):
|
def create_assignment(self, plan_id, **kw):
|
||||||
"""Get assignment details with progress."""
|
|
||||||
try:
|
try:
|
||||||
Assignment = request.env['encoach.course.plan.assignment'].sudo()
|
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||||
AssignmentDeliverable = request.env['encoach.course.plan.assignment.deliverable'].sudo()
|
if not plan.exists():
|
||||||
|
return _error_response('Plan not found', 404)
|
||||||
assignment = Assignment.browse(int(assignment_id))
|
body = _get_json_body() or {}
|
||||||
if not assignment.exists():
|
mode = (body.get('mode') or 'batch').strip()
|
||||||
return _error_response('Assignment not found', 404)
|
vals = {
|
||||||
|
'plan_id': plan.id,
|
||||||
# Get deliverable tracking
|
'mode': mode,
|
||||||
tracking = AssignmentDeliverable.search([('assignment_id', '=', int(assignment_id))])
|
'message': (body.get('message') or '').strip(),
|
||||||
status_counts = {}
|
'due_date': body.get('due_date') or False,
|
||||||
for t in tracking:
|
'state': 'active',
|
||||||
status_counts[t.status] = status_counts.get(t.status, 0) + 1
|
}
|
||||||
|
if mode == 'batch':
|
||||||
return _json_response({
|
if not body.get('batch_id'):
|
||||||
'data': assignment.to_api_dict(),
|
return _error_response('batch_id is required', 400)
|
||||||
'deliverables': {
|
vals['batch_id'] = int(body['batch_id'])
|
||||||
'total': len(tracking),
|
elif mode == 'students':
|
||||||
'by_status': status_counts,
|
ids = body.get('student_user_ids') or []
|
||||||
'items': [t.to_api_dict() for t in tracking],
|
if not isinstance(ids, list) or not ids:
|
||||||
},
|
return _error_response('student_user_ids is required', 400)
|
||||||
})
|
vals['student_user_ids'] = [(6, 0, [int(i) for i in ids])]
|
||||||
except Exception as exc:
|
else:
|
||||||
_logger.exception('course-plan.get_assignment failed')
|
return _error_response('Invalid mode', 400)
|
||||||
return _error_response(str(exc), 500)
|
rec = request.env['encoach.course.plan.assignment'].sudo().create(vals)
|
||||||
|
|
||||||
# PUT /api/ai/course-plan/assignments/<id>/deliverables/<del_id>
|
|
||||||
@http.route('/api/ai/course-plan/assignments/<int:assignment_id>/deliverables/<int:deliverable_id>',
|
|
||||||
type='http', auth='none', methods=['PUT'], csrf=False)
|
|
||||||
@jwt_required
|
|
||||||
def update_assignment_deliverable(self, assignment_id, deliverable_id, **kw):
|
|
||||||
"""Update deliverable status for an assignment."""
|
|
||||||
try:
|
|
||||||
body = _get_json_body()
|
|
||||||
AssignmentDeliverable = request.env['encoach.course.plan.assignment.deliverable'].sudo()
|
|
||||||
|
|
||||||
rec = AssignmentDeliverable.search([
|
|
||||||
('assignment_id', '=', int(assignment_id)),
|
|
||||||
('deliverable_id', '=', int(deliverable_id)),
|
|
||||||
], limit=1)
|
|
||||||
if not rec:
|
|
||||||
return _error_response('Assignment deliverable not found', 404)
|
|
||||||
|
|
||||||
vals = {}
|
|
||||||
if 'status' in body:
|
|
||||||
vals['status'] = body['status']
|
|
||||||
if 'score' in body:
|
|
||||||
vals['score'] = float(body['score'])
|
|
||||||
if 'notes' in body:
|
|
||||||
vals['notes'] = body['notes']
|
|
||||||
if body.get('status') == 'completed':
|
|
||||||
vals['completion_date'] = fields.Datetime.now()
|
|
||||||
|
|
||||||
rec.write(vals)
|
|
||||||
return _json_response({'data': rec.to_api_dict()})
|
return _json_response({'data': rec.to_api_dict()})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.exception('course-plan.update_assignment_deliverable failed')
|
_logger.exception('course-plan.create_assignment failed')
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
|
@http.route('/api/ai/course-plan/<int:plan_id>/assignments/<int:assignment_id>',
|
||||||
|
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def delete_assignment(self, plan_id, assignment_id, **kw):
|
||||||
|
try:
|
||||||
|
rec = request.env['encoach.course.plan.assignment'].sudo().browse(
|
||||||
|
int(assignment_id),
|
||||||
|
)
|
||||||
|
if not rec.exists() or rec.plan_id.id != int(plan_id):
|
||||||
|
return _error_response('Assignment not found', 404)
|
||||||
|
rec.unlink()
|
||||||
|
return _json_response({'success': True})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('course-plan.delete_assignment failed')
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
|
# ==================================================================
|
||||||
|
# PHASE E — Student-side endpoints
|
||||||
|
# ==================================================================
|
||||||
|
|
||||||
|
@http.route('/api/student/course-plans',
|
||||||
|
type='http', auth='none', methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def student_list_plans(self, **kw):
|
||||||
|
"""Return the course plans assigned to the authenticated user.
|
||||||
|
|
||||||
|
Visibility rules (OR):
|
||||||
|
1. There exists an assignment with ``mode='students'`` listing
|
||||||
|
the current user.
|
||||||
|
2. There exists an assignment with ``mode='batch'`` whose
|
||||||
|
batch contains an ``op.student.course`` whose student's
|
||||||
|
``user_id`` matches the current user.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = request.env.user
|
||||||
|
Assignment = request.env['encoach.course.plan.assignment'].sudo()
|
||||||
|
assignments = Assignment.search([
|
||||||
|
('state', '=', 'active'),
|
||||||
|
'|',
|
||||||
|
('student_user_ids', 'in', [user.id]),
|
||||||
|
'&', ('mode', '=', 'batch'), ('batch_id', '!=', False),
|
||||||
|
])
|
||||||
|
visible = []
|
||||||
|
for a in assignments:
|
||||||
|
if a.mode == 'students' and user.id in a.student_user_ids.ids:
|
||||||
|
visible.append(a)
|
||||||
|
continue
|
||||||
|
if a.mode == 'batch' and user.id in a.expand_user_ids():
|
||||||
|
visible.append(a)
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for a in visible:
|
||||||
|
if a.plan_id.id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(a.plan_id.id)
|
||||||
|
plan_data = a.plan_id.to_api_dict(
|
||||||
|
include_weeks=False, include_materials=False,
|
||||||
|
)
|
||||||
|
plan_data['assignment'] = a.to_api_dict()
|
||||||
|
out.append(plan_data)
|
||||||
|
return _json_response({'items': out, 'count': len(out)})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('course-plan.student_list_plans failed')
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
|
@http.route('/api/student/course-plans/<int:plan_id>',
|
||||||
|
type='http', auth='none', methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def student_get_plan(self, plan_id, **kw):
|
||||||
|
try:
|
||||||
|
user = request.env.user
|
||||||
|
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||||
|
if not plan.exists():
|
||||||
|
return _error_response('Plan not found', 404)
|
||||||
|
allowed = False
|
||||||
|
for a in plan.assignment_ids.filtered(lambda x: x.state == 'active'):
|
||||||
|
if a.mode == 'students' and user.id in a.student_user_ids.ids:
|
||||||
|
allowed = True
|
||||||
|
break
|
||||||
|
if a.mode == 'batch' and user.id in a.expand_user_ids():
|
||||||
|
allowed = True
|
||||||
|
break
|
||||||
|
if not allowed:
|
||||||
|
return _error_response('Plan not assigned to you', 403)
|
||||||
|
return _json_response({
|
||||||
|
'data': plan.to_api_dict(
|
||||||
|
include_weeks=True,
|
||||||
|
include_materials=True,
|
||||||
|
include_media=True,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('course-plan.student_get_plan failed')
|
||||||
return _error_response(str(exc), 500)
|
return _error_response(str(exc), 500)
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
from . import ai_generation_log
|
from . import ai_generation_log
|
||||||
from . import ai_ielts_generation_log
|
from . import ai_ielts_generation_log
|
||||||
from . import course_plan
|
from . import course_plan
|
||||||
|
from . import course_plan_source
|
||||||
|
from . import course_plan_media
|
||||||
|
from . import course_plan_assignment
|
||||||
|
|||||||
@@ -44,28 +44,10 @@ MATERIAL_TYPE_SELECTION = [
|
|||||||
('grammar_lesson', 'Grammar Lesson'),
|
('grammar_lesson', 'Grammar Lesson'),
|
||||||
('vocabulary_list', 'Vocabulary List'),
|
('vocabulary_list', 'Vocabulary List'),
|
||||||
('practice', 'Practice Exercises'),
|
('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'),
|
('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):
|
class CoursePlan(models.Model):
|
||||||
_name = 'encoach.course.plan'
|
_name = 'encoach.course.plan'
|
||||||
_description = 'AI-generated Course Plan'
|
_description = 'AI-generated Course Plan'
|
||||||
@@ -135,15 +117,30 @@ class CoursePlan(models.Model):
|
|||||||
material_ids = fields.One2many(
|
material_ids = fields.One2many(
|
||||||
'encoach.course.plan.material', 'plan_id', string='Materials',
|
'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)
|
week_count = fields.Integer(compute='_compute_counts', store=False)
|
||||||
material_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):
|
def _compute_counts(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
rec.week_count = len(rec.week_ids)
|
rec.week_count = len(rec.week_ids)
|
||||||
rec.material_count = len(rec.material_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
|
# Serialisation helpers — used by the REST controller so payload
|
||||||
@@ -157,7 +154,9 @@ class CoursePlan(models.Model):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return default
|
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()
|
self.ensure_one()
|
||||||
data = {
|
data = {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
@@ -177,12 +176,21 @@ class CoursePlan(models.Model):
|
|||||||
'resources': self._loads(self.resources_json, []),
|
'resources': self._loads(self.resources_json, []),
|
||||||
'week_count': len(self.week_ids),
|
'week_count': len(self.week_ids),
|
||||||
'material_count': len(self.material_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,
|
'created_at': self.create_date.isoformat() if self.create_date else None,
|
||||||
}
|
}
|
||||||
if include_weeks:
|
if include_weeks:
|
||||||
data['weeks'] = [w.to_api_dict() for w in self.week_ids.sorted('week_number')]
|
data['weeks'] = [w.to_api_dict() for w in self.week_ids.sorted('week_number')]
|
||||||
if include_materials:
|
if include_materials:
|
||||||
data['materials'] = [m.to_api_dict() for m in self.material_ids]
|
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
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -270,48 +278,8 @@ class CoursePlanMaterial(models.Model):
|
|||||||
'structured body is not needed.',
|
'structured body is not needed.',
|
||||||
)
|
)
|
||||||
|
|
||||||
def _loads(self, raw, default):
|
media_ids = fields.One2many(
|
||||||
if not raw:
|
'encoach.course.plan.media', 'material_id', string='Generated media',
|
||||||
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'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _loads(self, raw, default):
|
def _loads(self, raw, default):
|
||||||
@@ -322,9 +290,9 @@ class CoursePlanMaterial(models.Model):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def to_api_dict(self, include_media=False):
|
def to_api_dict(self, include_media=True):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
data = {
|
out = {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'plan_id': self.plan_id.id,
|
'plan_id': self.plan_id.id,
|
||||||
'week_id': self.week_id.id if self.week_id else None,
|
'week_id': self.week_id.id if self.week_id else None,
|
||||||
@@ -335,315 +303,7 @@ class CoursePlanMaterial(models.Model):
|
|||||||
'summary': self.summary or '',
|
'summary': self.summary or '',
|
||||||
'body': self._loads(self.body_json, {}),
|
'body': self._loads(self.body_json, {}),
|
||||||
'body_text': self.body_text or '',
|
'body_text': self.body_text or '',
|
||||||
'media_type': self.media_type or 'none',
|
|
||||||
'required_resources': self._loads(self.required_resources_json, []),
|
|
||||||
}
|
}
|
||||||
if include_media:
|
if include_media:
|
||||||
data['media_asset_url'] = self.media_asset_url or ''
|
out['media'] = [m.to_api_dict() for m in self.media_ids]
|
||||||
data['media_metadata'] = self._loads(self.media_metadata_json, {})
|
return out
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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''
|
||||||
@@ -4,7 +4,6 @@ access_encoach_ai_ielts_generation_log_user,encoach.ai.ielts.generation.log.user
|
|||||||
access_encoach_course_plan_user,encoach.course.plan.user,model_encoach_course_plan,base.group_user,1,1,1,1
|
access_encoach_course_plan_user,encoach.course.plan.user,model_encoach_course_plan,base.group_user,1,1,1,1
|
||||||
access_encoach_course_plan_week_user,encoach.course.plan.week.user,model_encoach_course_plan_week,base.group_user,1,1,1,1
|
access_encoach_course_plan_week_user,encoach.course.plan.week.user,model_encoach_course_plan_week,base.group_user,1,1,1,1
|
||||||
access_encoach_course_plan_material_user,encoach.course.plan.material.user,model_encoach_course_plan_material,base.group_user,1,1,1,1
|
access_encoach_course_plan_material_user,encoach.course.plan.material.user,model_encoach_course_plan_material,base.group_user,1,1,1,1
|
||||||
access_encoach_course_plan_deliverable_user,encoach.course.plan.deliverable.user,model_encoach_course_plan_deliverable,base.group_user,1,1,1,1
|
access_encoach_course_plan_source_user,encoach.course.plan.source.user,model_encoach_course_plan_source,base.group_user,1,1,1,1
|
||||||
access_encoach_course_plan_resource_dep_user,encoach.course.plan.resource.dep.user,model_encoach_course_plan_resource_dep,base.group_user,1,1,1,1
|
access_encoach_course_plan_media_user,encoach.course.plan.media.user,model_encoach_course_plan_media,base.group_user,1,1,1,1
|
||||||
access_encoach_course_plan_assignment_user,encoach.course.plan.assignment.user,model_encoach_course_plan_assignment,base.group_user,1,1,1,1
|
access_encoach_course_plan_assignment_user,encoach.course.plan.assignment.user,model_encoach_course_plan_assignment,base.group_user,1,1,1,1
|
||||||
access_encoach_course_plan_assignment_deliverable_user,encoach.course.plan.assignment.deliverable.user,model_encoach_course_plan_assignment_deliverable,base.group_user,1,1,1,1
|
|
||||||
|
|||||||
|
@@ -1,3 +1,6 @@
|
|||||||
from .english_pipeline import EnglishPipeline
|
from .english_pipeline import EnglishPipeline
|
||||||
from .ielts_pipeline import IeltsPipeline
|
from .ielts_pipeline import IeltsPipeline
|
||||||
from .course_plan_pipeline import CoursePlanPipeline
|
from .course_plan_pipeline import CoursePlanPipeline
|
||||||
|
from .source_indexer import SourceIndexer
|
||||||
|
from .media_service import MediaService
|
||||||
|
from .deliverables import compute_deliverables
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Course plan generation pipeline.
|
"""Course plan generation pipeline.
|
||||||
|
|
||||||
Three public entry points:
|
Two public entry points:
|
||||||
|
|
||||||
* :py:meth:`generate_plan` — given a short brief (course title, CEFR level,
|
* :py:meth:`generate_plan` — given a short brief (course title, CEFR level,
|
||||||
duration, skill coverage, grammar focus, resources), produce a full
|
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
|
+ practice, writing prompt, vocabulary list) and persist each as an
|
||||||
:py:class:`encoach.course.plan.material` row.
|
: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
|
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
|
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
|
model's output is. Any parse failure is swallowed and reported back
|
||||||
through the standard error channel so the caller can retry without the
|
through the standard error channel so the caller can retry without the
|
||||||
server crashing.
|
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
|
import json
|
||||||
@@ -199,12 +188,6 @@ class CoursePlanPipeline:
|
|||||||
# Decide once per instance whether to route through the LangGraph
|
# Decide once per instance whether to route through the LangGraph
|
||||||
# AgentRuntime or fall back to the direct chat_json path.
|
# AgentRuntime or fall back to the direct chat_json path.
|
||||||
self._use_agent = self._resolve_agent_flag(env)
|
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
|
@staticmethod
|
||||||
def _resolve_agent_flag(env):
|
def _resolve_agent_flag(env):
|
||||||
@@ -511,291 +494,3 @@ class CoursePlanPipeline:
|
|||||||
elif isinstance(value, dict):
|
elif isinstance(value, dict):
|
||||||
lines.append(f"{key}: " + json.dumps(value, ensure_ascii=False))
|
lines.append(f"{key}: " + json.dumps(value, ensure_ascii=False))
|
||||||
return "\n".join(lines)
|
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}'}
|
|
||||||
|
|||||||
158
backend/custom_addons/encoach_ai_course/services/deliverables.py
Normal file
158
backend/custom_addons/encoach_ai_course/services/deliverables.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ class EncoachEmbedding(models.Model):
|
|||||||
('feedback', 'Feedback'),
|
('feedback', 'Feedback'),
|
||||||
('generation_log', 'Generation Log'),
|
('generation_log', 'Generation Log'),
|
||||||
('material', 'Course Material'),
|
('material', 'Course Material'),
|
||||||
|
('course_plan_source', 'Course Plan Source'),
|
||||||
], required=True, index=True)
|
], required=True, index=True)
|
||||||
content_id = fields.Integer(required=True, index=True)
|
content_id = fields.Integer(required=True, index=True)
|
||||||
content_text = fields.Text()
|
content_text = fields.Text()
|
||||||
|
|||||||
@@ -1720,3 +1720,151 @@ docs/ENCOACH_FULL_DEMO_QA_REPORT.md # full QA write-up: credentials, dataset s
|
|||||||
- `khalid@encoach.test` was failing login because his password had drifted during earlier interactive testing. `reset_demo_passwords.py` now restores all canonical passwords idempotently.
|
- `khalid@encoach.test` was failing login because his password had drifted during earlier interactive testing. `reset_demo_passwords.py` now restores all canonical passwords idempotently.
|
||||||
- `encoach.ai.log` field names differ from a naive guess — the model uses `service`, `action`, `model_used`, `prompt_tokens`, `completion_tokens`, `total_tokens`, `input_preview`, `output_preview`. `encoach.ai.feedback` uses `subject_type`, `subject_id`, `rating in {'up','down'}` (NOT `'thumbs_up'`). The seeder now matches both definitions.
|
- `encoach.ai.log` field names differ from a naive guess — the model uses `service`, `action`, `model_used`, `prompt_tokens`, `completion_tokens`, `total_tokens`, `input_preview`, `output_preview`. `encoach.ai.feedback` uses `subject_type`, `subject_id`, `rating in {'up','down'}` (NOT `'thumbs_up'`). The seeder now matches both definitions.
|
||||||
- The exam approval *post-approval* hook only fires when the **final** stage approves, so step 3 above advances the request without publishing the exam yet — that's correct behaviour, just not obvious from the API alone. The DB-side verification in step 6 is what makes it observable.
|
- The exam approval *post-approval* hook only fires when the **final** stage approves, so step 3 above advances the request without publishing the exam yet — that's correct behaviour, just not obvious from the API alone. The DB-side verification in step 6 is what makes it observable.
|
||||||
|
|
||||||
|
## 24. AI Course-Plans — RAG, Multi-modal Media, Assignments (2026-04-25)
|
||||||
|
|
||||||
|
§22 made LangGraph the runtime; §24 puts a real product on top of it. The user asked for an AI-driven **course-plan** experience modelled on the GE1 outline: detect deliverables up-front (e.g. "12 reading texts, 12 listening scripts, 24 audio narrations"), let admins drop reference files / URLs / pasted notes the AI must respect (RAG), generate multi-modal training material (images via DALL-E 3, voice via AWS Polly / ElevenLabs, slideshow video via local `ffmpeg`), then **assign** the finished plan to a class or to specific students. This section is the implementation log.
|
||||||
|
|
||||||
|
### 24.1 What this section delivers
|
||||||
|
|
||||||
|
| Phase | Capability | Where it lives |
|
||||||
|
|---|---|---|
|
||||||
|
| **A** | Reference sources (file/URL/text) → pgvector, scoped to plan | `encoach.course.plan.source` + `services/source_indexer.py` |
|
||||||
|
| **B** | Deliverables preview & live progress strip | `services/deliverables.py` + `/api/ai/course-plan/<id>/deliverables` |
|
||||||
|
| **C** | On-demand audio / image / video per material + bulk-per-week | `encoach.course.plan.media` + `services/media_service.py` |
|
||||||
|
| **D** | Assign a plan to an `op.batch` or specific `res.users` | `encoach.course.plan.assignment` |
|
||||||
|
| **E** | Student-side list + drilldown (read-only) | `/api/student/course-plans*` + `/student/course-plans` page |
|
||||||
|
| **i18n** | Full EN + AR strings for every new control | `frontend/src/i18n/locales/{en,ar}.ts` |
|
||||||
|
|
||||||
|
### 24.2 New / extended backend artefacts
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/custom_addons/encoach_ai_course/
|
||||||
|
├── models/
|
||||||
|
│ ├── course_plan_source.py ← NEW RAG ingestion row (file|url|text)
|
||||||
|
│ ├── course_plan_media.py ← NEW audio|image|video asset
|
||||||
|
│ └── course_plan_assignment.py ← NEW visibility unit (batch|students)
|
||||||
|
├── services/
|
||||||
|
│ ├── source_indexer.py ← NEW PDF/DOCX/HTML extraction → pgvector
|
||||||
|
│ ├── deliverables.py ← NEW per-week status + percent_ready
|
||||||
|
│ └── media_service.py ← NEW Polly/ElevenLabs/DALL-E/ffmpeg
|
||||||
|
└── controllers/course_plan.py ← +18 endpoints (sources, media, assignments, student-side)
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing models updated:
|
||||||
|
|
||||||
|
* `encoach.course.plan` gains `source_ids`, `media_ids`, `assignment_ids`, `to_api_dict(include_media=...)`.
|
||||||
|
* `encoach.ai.tool.category` selection extends with `('media', 'Media generation')` so the new tools register cleanly.
|
||||||
|
* `encoach.embedding` queries gain a `plan_id` filter — `resources.search` is now plan-scoped when passed `plan_id`, so the agent sees only its own corpus.
|
||||||
|
|
||||||
|
New tools (registered in `encoach_ai/data/agents_defaults.xml`):
|
||||||
|
|
||||||
|
| Tool key | Provider | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `media.synthesize_audio` | AWS Polly (default) / ElevenLabs | Used for `listening_script` + `speaking_prompt` |
|
||||||
|
| `media.generate_image` | OpenAI DALL-E 3 | Capped per plan via `encoach_ai_course.image_budget_per_plan` (default 60) |
|
||||||
|
| `media.compose_video` | local `ffmpeg` | Auto-creates audio + image first if missing |
|
||||||
|
|
||||||
|
New agent: **`course_media_director`** — orchestrates per-week media batches; reuses `course_week_materials`'s tools plus the three media tools above. The base `course_week_materials` agent now also has `media.synthesize_audio` + `media.generate_image` in its tool set so a single LangGraph turn can emit the text material *and* its narration/illustration.
|
||||||
|
|
||||||
|
### 24.3 New API endpoints (all JWT-protected unless noted)
|
||||||
|
|
||||||
|
```text
|
||||||
|
# Sources (Phase A)
|
||||||
|
POST /api/ai/course-plan/<id>/sources create URL or text source
|
||||||
|
POST /api/ai/course-plan/<id>/sources/upload multipart file upload
|
||||||
|
GET /api/ai/course-plan/<id>/sources list sources for plan
|
||||||
|
POST /api/ai/course-plan/sources/<sid>/reindex re-extract + re-embed
|
||||||
|
DELETE /api/ai/course-plan/sources/<sid> cascade-deletes embeddings
|
||||||
|
|
||||||
|
# Deliverables (Phase B)
|
||||||
|
GET /api/ai/course-plan/<id>/deliverables {summary, weeks: [...]}
|
||||||
|
|
||||||
|
# Media (Phase C)
|
||||||
|
GET /api/ai/course-plan/material/<mid>/media list media for material
|
||||||
|
POST /api/ai/course-plan/material/<mid>/media/audio generate audio
|
||||||
|
POST /api/ai/course-plan/material/<mid>/media/image generate image
|
||||||
|
POST /api/ai/course-plan/material/<mid>/media/video compose video
|
||||||
|
DELETE /api/ai/course-plan/media/<media_id> delete media + attachment
|
||||||
|
POST /api/ai/course-plan/<id>/week/<n>/media bulk-generate week (audio+image)
|
||||||
|
|
||||||
|
# Assignments (Phase D)
|
||||||
|
GET /api/ai/course-plan/<id>/assignments
|
||||||
|
POST /api/ai/course-plan/<id>/assignments
|
||||||
|
DELETE /api/ai/course-plan/assignments/<aid>
|
||||||
|
|
||||||
|
# Student-side (Phase E)
|
||||||
|
GET /api/student/course-plans list assigned plans
|
||||||
|
GET /api/student/course-plans/<id> read-only drilldown w/ media URLs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 24.4 New / updated frontend surfaces
|
||||||
|
|
||||||
|
* **Wizard** (`pages/admin/wizards/CoursePlanWizard.tsx`): now 6 steps — Brief → Resources (source builder) → Deliverables preview → Multimedia toggles → Review → Generate. Sources are queued client-side and uploaded post-creation against the new plan id.
|
||||||
|
* **Detail page** (`pages/admin/AdminCoursePlanDetail.tsx`): now shows `DeliverablesStrip`, `SourcesCard`, `AssignmentsCard` (with `AssignDialog`), and per-material `MediaDrawer` (audio/image/video preview, generate, download, delete). Plus a "Generate week media" bulk action per week.
|
||||||
|
* **Student** (`pages/student/StudentCoursePlans.tsx`, `StudentCoursePlanDetail.tsx`): list + read-only drilldown, `<audio>` / `<img>` / `<video>` tiles fed from `/web/content/<attachment_id>`.
|
||||||
|
* **Sidebar**: new "My Course Plans" entry under student nav (`StudentLayout.tsx`).
|
||||||
|
* **i18n**: ~140 new keys in EN + AR across `coursePlan.wizard.*`, `coursePlan.sources.*`, `coursePlan.deliverables.*`, `coursePlan.media.*`, `coursePlan.assignments.*`, `coursePlan.student.*`, `nav.myCoursePlans`.
|
||||||
|
|
||||||
|
### 24.5 Smoke test (run via `odoo-bin shell`)
|
||||||
|
|
||||||
|
`smoke_course_plan.py` exercises every phase end-to-end:
|
||||||
|
|
||||||
|
| Phase | Result |
|
||||||
|
|---|---|
|
||||||
|
| A — index inline-text source | PASS · 1 chunk · 295 chars (uses local MiniLM embeddings) |
|
||||||
|
| B — `compute_deliverables` | PASS · 12 weeks · 5 ready / 1 generated / 0 planned |
|
||||||
|
| C — DALL-E 3 image | PASS · 753 KB PNG generated for a `reading_text` material |
|
||||||
|
| C — Polly audio | Gracefully marked `failed` with `"AWS credentials not configured — set in AI Settings"` (expected on dev box) |
|
||||||
|
| D — assignment | PASS · created `mode='students'` assignment for Sarah |
|
||||||
|
| E — visibility | PASS · `expand_user_ids()` returns Sarah · plan in student listing |
|
||||||
|
|
||||||
|
Run it any time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.conda-envs/odoo19/bin/python odoo/odoo-bin shell \
|
||||||
|
-c odoo.conf -d encoach_v2 --no-http \
|
||||||
|
< smoke_course_plan.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 24.6 Operational notes
|
||||||
|
|
||||||
|
* **External providers are optional**. AWS Polly / ElevenLabs / OpenAI image keys live in **AI Settings** (`encoach.ai.settings`); when missing the corresponding media row is created with `status='failed'` and a clear error — the rest of the flow (planning, RAG, deliverables, assignment) still works.
|
||||||
|
* **`ffmpeg`** must be on `PATH` for video composition. If it's missing, the video media row is `failed` with a one-line install hint; nothing else breaks.
|
||||||
|
* **Image budget** per plan is the system parameter `encoach_ai_course.image_budget_per_plan` (default 60). Bump it via Settings → Technical → System Parameters.
|
||||||
|
* **Plan-scoped retrieval**. The `resources.search` tool now accepts a `plan_id`; the LLM is auto-given the plan id in the system prompt of `course_planner` and `course_week_materials`, so RAG only ever sees that plan's own sources.
|
||||||
|
* **Cascade behaviour**. Deleting a source unlinks its `encoach.embedding` rows; deleting a plan unlinks sources, media, assignments, and the underlying `ir.attachment`s.
|
||||||
|
|
||||||
|
### 24.7 Files added / changed in this pass
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/custom_addons/encoach_ai_course/models/course_plan_source.py + new
|
||||||
|
backend/custom_addons/encoach_ai_course/models/course_plan_media.py + new
|
||||||
|
backend/custom_addons/encoach_ai_course/models/course_plan_assignment.py + new
|
||||||
|
backend/custom_addons/encoach_ai_course/services/source_indexer.py + new
|
||||||
|
backend/custom_addons/encoach_ai_course/services/deliverables.py + new
|
||||||
|
backend/custom_addons/encoach_ai_course/services/media_service.py + new
|
||||||
|
backend/custom_addons/encoach_ai_course/controllers/course_plan.py ~ +480 LOC
|
||||||
|
backend/custom_addons/encoach_ai_course/security/ir.model.access.csv ~ access rights
|
||||||
|
backend/custom_addons/encoach_ai/models/ai_agent.py ~ TOOL_CATEGORIES += media
|
||||||
|
backend/custom_addons/encoach_ai/data/agents_defaults.xml ~ + 3 tools, + 1 agent
|
||||||
|
backend/custom_addons/encoach_vector/models/embedding.py ~ plan_id filter
|
||||||
|
frontend/src/types/coursePlan.ts ~ +4 interfaces
|
||||||
|
frontend/src/services/coursePlan.service.ts ~ +18 methods
|
||||||
|
frontend/src/pages/admin/wizards/CoursePlanWizard.tsx ~ +SourcesStep, MediaStep
|
||||||
|
frontend/src/pages/admin/AdminCoursePlanDetail.tsx ~ rewrite (Deliverables, Sources, Assignments, MediaDrawer)
|
||||||
|
frontend/src/pages/student/StudentCoursePlans.tsx + new
|
||||||
|
frontend/src/pages/student/StudentCoursePlanDetail.tsx + new
|
||||||
|
frontend/src/App.tsx ~ student routes
|
||||||
|
frontend/src/components/StudentLayout.tsx ~ My Course Plans link
|
||||||
|
frontend/src/i18n/locales/en.ts ~ +140 keys
|
||||||
|
frontend/src/i18n/locales/ar.ts ~ +140 keys
|
||||||
|
smoke_course_plan.py + new (E2E smoke test)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 24.8 Gotchas resolved during this pass
|
||||||
|
|
||||||
|
* `encoach.ai.tool.category` is a strict `Selection`; adding the three media tools required adding `('media', 'Media generation')` to `TOOL_CATEGORIES` in `ai_agent.py`. Without that the data file refused to load and the whole `encoach_ai` module rolled back its upgrade — silently leaving the new course-plan tables uncreated until we restarted with `-u encoach_vector,encoach_ai,encoach_ai_course`.
|
||||||
|
* Odoo XML data loaders refuse two `<record id="..."/>` with the same id in the same file (no implicit merge). The `course_week_materials` agent had to be edited *in place* with the new media tools instead of being patched as a second record at the bottom.
|
||||||
|
* `pypdf` extracts most encrypted-but-public PDFs; if both `pypdf` and `PyPDF2` are missing the indexer marks the source `failed` with a clear pip hint rather than crashing.
|
||||||
|
* The student-side endpoint uses `expand_user_ids()` so a `mode='batch'` assignment auto-includes any future student rolled into that batch — no need to re-assign per intake.
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscuss
|
|||||||
const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
|
const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
|
||||||
const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
|
const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
|
||||||
const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
|
const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
|
||||||
|
const StudentCoursePlans = lazy(() => import("@/pages/student/StudentCoursePlans"));
|
||||||
|
const StudentCoursePlanDetail = lazy(() => import("@/pages/student/StudentCoursePlanDetail"));
|
||||||
const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
|
const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
|
||||||
const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
|
const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
|
||||||
const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
|
const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
|
||||||
@@ -269,6 +271,8 @@ const App = () => (
|
|||||||
<Route path="/student/placement/access" element={<PlacementAccess />} />
|
<Route path="/student/placement/access" element={<PlacementAccess />} />
|
||||||
<Route path="/student/exam/:examId/results" element={<ExamResults />} />
|
<Route path="/student/exam/:examId/results" element={<ExamResults />} />
|
||||||
<Route path="/student/course/generate" element={<GapAnalysis />} />
|
<Route path="/student/course/generate" element={<GapAnalysis />} />
|
||||||
|
<Route path="/student/course-plans" element={<StudentCoursePlans />} />
|
||||||
|
<Route path="/student/course-plans/:planId" element={<StudentCoursePlanDetail />} />
|
||||||
<Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} />
|
<Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} />
|
||||||
<Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} />
|
<Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} />
|
||||||
<Route path="/student/course/:courseId" element={<CourseDelivery />} />
|
<Route path="/student/course/:courseId" element={<CourseDelivery />} />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import ExamPopup from "./student/ExamPopup";
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
||||||
CalendarCheck, Calendar, User, Target, ListChecks,
|
CalendarCheck, Calendar, User, Target, ListChecks,
|
||||||
MessageSquare, Megaphone, Mail, TrendingUp,
|
MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,6 +17,7 @@ const navGroups: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
||||||
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
|
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
|
||||||
|
{ titleKey: "nav.myCoursePlans", url: "/student/course-plans", icon: Sparkles },
|
||||||
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
|
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
|
||||||
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
|
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const ar: Translations = {
|
|||||||
dashboard: "لوحة التحكم",
|
dashboard: "لوحة التحكم",
|
||||||
courses: "الدورات",
|
courses: "الدورات",
|
||||||
myCourses: "دوراتي",
|
myCourses: "دوراتي",
|
||||||
|
myCoursePlans: "خططي الدراسية",
|
||||||
students: "الطلاب",
|
students: "الطلاب",
|
||||||
teachers: "المعلمون",
|
teachers: "المعلمون",
|
||||||
batches: "الدفعات",
|
batches: "الدفعات",
|
||||||
@@ -739,8 +740,14 @@ const ar: Translations = {
|
|||||||
basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.",
|
basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.",
|
||||||
coverage: "التغطية",
|
coverage: "التغطية",
|
||||||
coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.",
|
coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.",
|
||||||
|
sources: "المصادر المرجعية",
|
||||||
|
sourcesDesc:
|
||||||
|
"ارفع ملفات PDF أو روابط أو نصوص. يستخدمها الذكاء الاصطناعي كمرجع لتوليد مواد كل أسبوع.",
|
||||||
scope: "النطاق",
|
scope: "النطاق",
|
||||||
scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.",
|
scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.",
|
||||||
|
media: "الوسائط",
|
||||||
|
mediaDesc:
|
||||||
|
"اختر أي وسائط يولّدها الذكاء الاصطناعي تلقائياً مع المواد النصّية.",
|
||||||
review: "المراجعة",
|
review: "المراجعة",
|
||||||
reviewDesc: "راجع البيانات قبل بدء التوليد.",
|
reviewDesc: "راجع البيانات قبل بدء التوليد.",
|
||||||
},
|
},
|
||||||
@@ -767,7 +774,155 @@ const ar: Translations = {
|
|||||||
cefrRequired: "يرجى اختيار مستوى CEFR.",
|
cefrRequired: "يرجى اختيار مستوى CEFR.",
|
||||||
weeksRange: "عدد الأسابيع يجب أن يكون 1 على الأقل.",
|
weeksRange: "عدد الأسابيع يجب أن يكون 1 على الأقل.",
|
||||||
},
|
},
|
||||||
|
review: {
|
||||||
|
sourcesCount_one: "{{count}} مصدر",
|
||||||
|
sourcesCount_other: "{{count}} مصادر",
|
||||||
|
noSources: "لا توجد مصادر",
|
||||||
|
mediaNone: "لا يوجد توليد وسائط",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
sections_extras: {
|
||||||
|
sources: "المصادر المرجعية",
|
||||||
|
progress: "تقدّم التوليد",
|
||||||
|
assignments: "الإسنادات",
|
||||||
|
media: "الوسائط المُولَّدة",
|
||||||
|
},
|
||||||
|
sources: {
|
||||||
|
sectionTitle: "المصادر المرجعية (RAG)",
|
||||||
|
sectionDesc:
|
||||||
|
"ارفع PDF أو أضف روابط أو نصوصاً. يستخدمها الذكاء الاصطناعي كمرجع عند توليد المواد الأسبوعية.",
|
||||||
|
collected_one: "{{count}} مصدر جاهز",
|
||||||
|
collected_other: "{{count}} مصادر جاهزة",
|
||||||
|
empty: "لا توجد مصادر مرجعية بعد.",
|
||||||
|
dropTitle: "أضف مواد مرجعية",
|
||||||
|
dropHint: "PDF أو DOCX أو TXT أو HTML أو نص — حتى بضعة ميغابايت لكل ملف.",
|
||||||
|
uploadFiles: "رفع ملفات",
|
||||||
|
urlLabel: "إضافة رابط",
|
||||||
|
textLabel: "أو ألصق نصاً مباشرة",
|
||||||
|
textPlaceholder: "ألصق فقرة لتوجيه الذكاء الاصطناعي…",
|
||||||
|
uploadFailed: "تعذّر رفع \"{{name}}\".",
|
||||||
|
reindex: "إعادة فهرسة",
|
||||||
|
reindexed: "أُعيدت فهرسة المصدر.",
|
||||||
|
reindexFailed: "فشلت إعادة الفهرسة.",
|
||||||
|
deleted: "تم حذف المصدر.",
|
||||||
|
deleteFailed: "تعذّر حذف المصدر.",
|
||||||
|
status: {
|
||||||
|
pending: "في الانتظار",
|
||||||
|
indexing: "يُفهرس…",
|
||||||
|
indexed: "مُفهرس",
|
||||||
|
failed: "فشل",
|
||||||
|
},
|
||||||
|
kindLabel: {
|
||||||
|
file: "ملف",
|
||||||
|
url: "رابط",
|
||||||
|
text: "نص",
|
||||||
|
},
|
||||||
|
chunks_one: "{{count}} جزء",
|
||||||
|
chunks_other: "{{count}} أجزاء",
|
||||||
|
},
|
||||||
|
sourceKind: {
|
||||||
|
file: "ملف",
|
||||||
|
url: "رابط",
|
||||||
|
text: "نص",
|
||||||
|
},
|
||||||
|
deliverables: {
|
||||||
|
title: "تقدّم التوليد",
|
||||||
|
subtitle:
|
||||||
|
"ما الذي سيُنتجه الذكاء الاصطناعي، وما تم إنتاجه، وما اكتمل تماماً (مواد + وسائط).",
|
||||||
|
summary: {
|
||||||
|
planned_one: "{{count}} مخطّط",
|
||||||
|
planned_other: "{{count}} مخطّط",
|
||||||
|
generated_one: "{{count}} مُولَّد",
|
||||||
|
generated_other: "{{count}} مُولَّد",
|
||||||
|
ready_one: "{{count}} جاهز",
|
||||||
|
ready_other: "{{count}} جاهز",
|
||||||
|
},
|
||||||
|
mediaCounts: "صوت {{audio}} · صورة {{image}} · فيديو {{video}}",
|
||||||
|
status: {
|
||||||
|
planned: "مخطّط",
|
||||||
|
generated: "مُولَّد",
|
||||||
|
ready: "جاهز",
|
||||||
|
},
|
||||||
|
perWeek: "تفصيل لكل أسبوع",
|
||||||
|
noWeeks: "لا توجد أسابيع — ولّد الخطة أولاً.",
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
audio: "صوت",
|
||||||
|
image: "صورة",
|
||||||
|
video: "فيديو",
|
||||||
|
audioTitle: "تعليق صوتي",
|
||||||
|
audioDesc:
|
||||||
|
"توليد صوت منطوق (Polly / ElevenLabs) لنصوص الاستماع ومحفّزات المحادثة.",
|
||||||
|
imageTitle: "صور تغطية",
|
||||||
|
imageDesc:
|
||||||
|
"توليد صور DALL·E 3 لنصوص القراءة والاستماع. يحتسب من ميزانية الصور للخطة.",
|
||||||
|
videoTitle: "فيديو شرائح",
|
||||||
|
videoDesc: "دمج الصوت + الصورة في فيديو MP4 قصير عبر ffmpeg. أبطأ الخيارات.",
|
||||||
|
hint:
|
||||||
|
"الصوت مفعّل افتراضياً. الفيديو معطّل افتراضياً — فعّله بعد مراجعة الصوت والصورة.",
|
||||||
|
drawerTitle: "وسائط \"{{title}}\"",
|
||||||
|
drawerSubtitle: "ولّد أو احذف الصوت والصورة والفيديو.",
|
||||||
|
generateAudio: "توليد صوت",
|
||||||
|
generateImage: "توليد صورة",
|
||||||
|
generateVideo: "توليد فيديو",
|
||||||
|
generating: "جاري التوليد…",
|
||||||
|
audioReady: "الصوت جاهز",
|
||||||
|
imageReady: "الصورة جاهزة",
|
||||||
|
videoReady: "الفيديو جاهز",
|
||||||
|
audioFailed: "فشل توليد الصوت.",
|
||||||
|
imageFailed: "فشل توليد الصورة.",
|
||||||
|
videoFailed: "فشل توليد الفيديو.",
|
||||||
|
deleted: "تم حذف الوسيط.",
|
||||||
|
deleteFailed: "تعذّر حذف الوسيط.",
|
||||||
|
open: "فتح",
|
||||||
|
download: "تنزيل",
|
||||||
|
noMedia: "لا توجد وسائط لهذه المادة بعد.",
|
||||||
|
mediaForMaterial: "وسائط",
|
||||||
|
bulk: "توليد وسائط الأسبوع",
|
||||||
|
bulkSuccess: "اكتمل توليد وسائط الأسبوع.",
|
||||||
|
bulkFailed: "تعذّر توليد وسائط الأسبوع.",
|
||||||
|
provider: "المزوّد",
|
||||||
|
},
|
||||||
|
assignments: {
|
||||||
|
title: "مُسندة إلى",
|
||||||
|
empty: "لم تُسند إلى أحد بعد.",
|
||||||
|
assign: "إسناد",
|
||||||
|
assignTitle: "إسناد خطة المقرر",
|
||||||
|
assignSubtitle:
|
||||||
|
"اختر شعبة (دفعة) للجميع، أو طلاباً محدّدين. ستظهر الخطة في لوحة الطالب.",
|
||||||
|
modeBatch: "دفعة كاملة",
|
||||||
|
modeStudents: "طلاب محدّدون",
|
||||||
|
pickBatch: "اختر دفعة",
|
||||||
|
pickStudents: "اختر طلاباً",
|
||||||
|
noBatches: "لا توجد دفعات.",
|
||||||
|
noStudents: "لا يوجد طلاب.",
|
||||||
|
dueDate: "تاريخ التسليم (اختياري)",
|
||||||
|
message: "رسالة للمتعلّمين (اختياري)",
|
||||||
|
messagePlaceholder: "ترحيب، توقّعات، مواعيد…",
|
||||||
|
created: "تم إسناد الخطة.",
|
||||||
|
createFailed: "تعذّر إسناد الخطة.",
|
||||||
|
removed: "تمت إزالة الإسناد.",
|
||||||
|
removeFailed: "تعذّر إزالة الإسناد.",
|
||||||
|
remove: "إزالة",
|
||||||
|
confirmRemove: "إزالة هذا الإسناد؟",
|
||||||
|
assignedBy: "أسندها {{name}}",
|
||||||
|
students_one: "{{count}} طالب",
|
||||||
|
students_other: "{{count}} طلاب",
|
||||||
|
cancel: "إلغاء",
|
||||||
|
save: "إسناد",
|
||||||
|
},
|
||||||
|
student: {
|
||||||
|
listTitle: "خططي الدراسية",
|
||||||
|
listSubtitle:
|
||||||
|
"خطط مناهج مخصّصة يُنشئها الذكاء الاصطناعي ويُسندها معلّمك. افتح خطة لرؤية الأسابيع والمواد والوسائط.",
|
||||||
|
empty: "لم تُسند إليك خطط بعد.",
|
||||||
|
open: "فتح المقرر",
|
||||||
|
assignedBy: "أسندها {{name}}",
|
||||||
|
due: "موعد التسليم {{date}}",
|
||||||
|
noDue: "بدون موعد",
|
||||||
|
backToList: "العودة إلى خططي",
|
||||||
|
},
|
||||||
|
wizardSourcesStep: "المصادر",
|
||||||
},
|
},
|
||||||
aiAdmin: {
|
aiAdmin: {
|
||||||
title: "وكلاء الذكاء الاصطناعي والأدوات",
|
title: "وكلاء الذكاء الاصطناعي والأدوات",
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ const en: Translations = {
|
|||||||
dashboard: "Dashboard",
|
dashboard: "Dashboard",
|
||||||
courses: "Courses",
|
courses: "Courses",
|
||||||
myCourses: "My Courses",
|
myCourses: "My Courses",
|
||||||
|
myCoursePlans: "My Course Plans",
|
||||||
students: "Students",
|
students: "Students",
|
||||||
teachers: "Teachers",
|
teachers: "Teachers",
|
||||||
batches: "Batches",
|
batches: "Batches",
|
||||||
@@ -796,8 +797,14 @@ const en: Translations = {
|
|||||||
basicsDesc: "Name the course and set level, duration, and weekly hours.",
|
basicsDesc: "Name the course and set level, duration, and weekly hours.",
|
||||||
coverage: "Coverage",
|
coverage: "Coverage",
|
||||||
coverageDesc: "Tell the AI how hours split across skills and who the learners are.",
|
coverageDesc: "Tell the AI how hours split across skills and who the learners are.",
|
||||||
|
sources: "Reference sources",
|
||||||
|
sourcesDesc:
|
||||||
|
"Drop PDFs, URLs, or pasted text. The AI uses them as grounding when generating each week's materials.",
|
||||||
scope: "Scope",
|
scope: "Scope",
|
||||||
scopeDesc: "Optional: grammar focus, resources to reference, free-form notes.",
|
scopeDesc: "Optional: grammar focus, resources to reference, free-form notes.",
|
||||||
|
media: "Multimedia",
|
||||||
|
mediaDesc:
|
||||||
|
"Pick which media the AI should auto-produce alongside the text materials.",
|
||||||
review: "Review",
|
review: "Review",
|
||||||
reviewDesc: "Double-check your brief before kicking off the generation.",
|
reviewDesc: "Double-check your brief before kicking off the generation.",
|
||||||
},
|
},
|
||||||
@@ -825,7 +832,156 @@ const en: Translations = {
|
|||||||
cefrRequired: "Please pick a CEFR level.",
|
cefrRequired: "Please pick a CEFR level.",
|
||||||
weeksRange: "Total weeks must be at least 1.",
|
weeksRange: "Total weeks must be at least 1.",
|
||||||
},
|
},
|
||||||
|
review: {
|
||||||
|
sourcesCount_one: "{{count}} reference source",
|
||||||
|
sourcesCount_other: "{{count}} reference sources",
|
||||||
|
noSources: "No reference sources",
|
||||||
|
mediaNone: "No media generation",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
sections_extras: {
|
||||||
|
sources: "Reference sources",
|
||||||
|
progress: "Generation progress",
|
||||||
|
assignments: "Assignments",
|
||||||
|
media: "Generated media",
|
||||||
|
},
|
||||||
|
sources: {
|
||||||
|
sectionTitle: "Reference sources (RAG)",
|
||||||
|
sectionDesc:
|
||||||
|
"Upload PDFs, paste URLs, or drop in raw text. The AI will use the indexed content as grounding when it writes weekly materials.",
|
||||||
|
collected_one: "{{count}} source ready",
|
||||||
|
collected_other: "{{count}} sources ready",
|
||||||
|
empty: "No reference sources yet.",
|
||||||
|
dropTitle: "Add reference materials",
|
||||||
|
dropHint: "PDF, DOCX, TXT, HTML, or plain text — up to a few MB each.",
|
||||||
|
uploadFiles: "Upload files",
|
||||||
|
urlLabel: "Add a URL",
|
||||||
|
textLabel: "Or paste text directly",
|
||||||
|
textPlaceholder: "Paste a passage to ground the AI on…",
|
||||||
|
uploadFailed: "Couldn't upload \"{{name}}\".",
|
||||||
|
reindex: "Reindex",
|
||||||
|
reindexed: "Source reindexed.",
|
||||||
|
reindexFailed: "Reindex failed.",
|
||||||
|
deleted: "Source deleted.",
|
||||||
|
deleteFailed: "Couldn't delete source.",
|
||||||
|
status: {
|
||||||
|
pending: "Pending",
|
||||||
|
indexing: "Indexing…",
|
||||||
|
indexed: "Indexed",
|
||||||
|
failed: "Failed",
|
||||||
|
},
|
||||||
|
kindLabel: {
|
||||||
|
file: "File",
|
||||||
|
url: "URL",
|
||||||
|
text: "Text",
|
||||||
|
},
|
||||||
|
chunks_one: "{{count}} chunk",
|
||||||
|
chunks_other: "{{count}} chunks",
|
||||||
|
},
|
||||||
|
sourceKind: {
|
||||||
|
file: "File",
|
||||||
|
url: "URL",
|
||||||
|
text: "Inline text",
|
||||||
|
},
|
||||||
|
deliverables: {
|
||||||
|
title: "Generation progress",
|
||||||
|
subtitle:
|
||||||
|
"What the AI is going to produce, what it has produced, and what is fully ready (materials + media).",
|
||||||
|
summary: {
|
||||||
|
planned_one: "{{count}} planned",
|
||||||
|
planned_other: "{{count}} planned",
|
||||||
|
generated_one: "{{count}} generated",
|
||||||
|
generated_other: "{{count}} generated",
|
||||||
|
ready_one: "{{count}} ready",
|
||||||
|
ready_other: "{{count}} ready",
|
||||||
|
},
|
||||||
|
mediaCounts: "Audio {{audio}} · Image {{image}} · Video {{video}}",
|
||||||
|
status: {
|
||||||
|
planned: "Planned",
|
||||||
|
generated: "Generated",
|
||||||
|
ready: "Ready",
|
||||||
|
},
|
||||||
|
perWeek: "Per-week breakdown",
|
||||||
|
noWeeks: "No weeks yet — generate the plan first.",
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
audio: "Audio",
|
||||||
|
image: "Image",
|
||||||
|
video: "Video",
|
||||||
|
audioTitle: "Narration audio",
|
||||||
|
audioDesc:
|
||||||
|
"Generate spoken audio (Polly / ElevenLabs) for listening scripts and speaking prompts.",
|
||||||
|
imageTitle: "Cover images",
|
||||||
|
imageDesc:
|
||||||
|
"Generate DALL·E 3 images for reading texts and listening scripts. Counts against the per-plan image budget.",
|
||||||
|
videoTitle: "Slideshow video",
|
||||||
|
videoDesc:
|
||||||
|
"Combine audio + image into a short MP4 with ffmpeg. Slowest option.",
|
||||||
|
hint:
|
||||||
|
"Audio is on by default. Video is off by default — turn it on once you've reviewed the audio + image.",
|
||||||
|
drawerTitle: "Media for \"{{title}}\"",
|
||||||
|
drawerSubtitle: "Generate or remove audio, images, and slideshow video.",
|
||||||
|
generateAudio: "Generate audio",
|
||||||
|
generateImage: "Generate image",
|
||||||
|
generateVideo: "Generate video",
|
||||||
|
generating: "Generating…",
|
||||||
|
audioReady: "Audio ready",
|
||||||
|
imageReady: "Image ready",
|
||||||
|
videoReady: "Video ready",
|
||||||
|
audioFailed: "Audio generation failed.",
|
||||||
|
imageFailed: "Image generation failed.",
|
||||||
|
videoFailed: "Video generation failed.",
|
||||||
|
deleted: "Media deleted.",
|
||||||
|
deleteFailed: "Couldn't delete media.",
|
||||||
|
open: "Open",
|
||||||
|
download: "Download",
|
||||||
|
noMedia: "No media yet for this material.",
|
||||||
|
mediaForMaterial: "Media",
|
||||||
|
bulk: "Generate week media",
|
||||||
|
bulkSuccess: "Week media generation done.",
|
||||||
|
bulkFailed: "Couldn't generate week media.",
|
||||||
|
provider: "Provider",
|
||||||
|
},
|
||||||
|
assignments: {
|
||||||
|
title: "Assigned to",
|
||||||
|
empty: "Not assigned to anyone yet.",
|
||||||
|
assign: "Assign",
|
||||||
|
assignTitle: "Assign this course plan",
|
||||||
|
assignSubtitle:
|
||||||
|
"Pick a class (batch) for everyone, or individual students. They'll see the plan in their student dashboard.",
|
||||||
|
modeBatch: "Whole class (batch)",
|
||||||
|
modeStudents: "Individual students",
|
||||||
|
pickBatch: "Select a batch",
|
||||||
|
pickStudents: "Pick students",
|
||||||
|
noBatches: "No batches available.",
|
||||||
|
noStudents: "No students available.",
|
||||||
|
dueDate: "Due date (optional)",
|
||||||
|
message: "Message to learners (optional)",
|
||||||
|
messagePlaceholder: "Welcome note, expectations, deadlines…",
|
||||||
|
created: "Plan assigned.",
|
||||||
|
createFailed: "Couldn't assign plan.",
|
||||||
|
removed: "Assignment removed.",
|
||||||
|
removeFailed: "Couldn't remove assignment.",
|
||||||
|
remove: "Remove",
|
||||||
|
confirmRemove: "Remove this assignment?",
|
||||||
|
assignedBy: "Assigned by {{name}}",
|
||||||
|
students_one: "{{count}} student",
|
||||||
|
students_other: "{{count}} students",
|
||||||
|
cancel: "Cancel",
|
||||||
|
save: "Assign",
|
||||||
|
},
|
||||||
|
student: {
|
||||||
|
listTitle: "My course plans",
|
||||||
|
listSubtitle:
|
||||||
|
"Personalised AI-generated curricula assigned by your teacher. Open one to see the weekly plan, materials, and media.",
|
||||||
|
empty: "No plans assigned to you yet.",
|
||||||
|
open: "Open course",
|
||||||
|
assignedBy: "Assigned by {{name}}",
|
||||||
|
due: "Due {{date}}",
|
||||||
|
noDue: "No deadline",
|
||||||
|
backToList: "Back to my plans",
|
||||||
|
},
|
||||||
|
wizardSourcesStep: "Sources",
|
||||||
},
|
},
|
||||||
aiAdmin: {
|
aiAdmin: {
|
||||||
title: "AI Agents & Tools",
|
title: "AI Agents & Tools",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,19 @@
|
|||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { X } from "lucide-react";
|
import {
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Link2,
|
||||||
|
Mic,
|
||||||
|
Music,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
Video,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
|
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -38,6 +48,33 @@ import type { CoursePlanGenerateBrief } from "@/types";
|
|||||||
* materials immediately.
|
* materials immediately.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
type DraftSourceKind = "file" | "url" | "text";
|
||||||
|
|
||||||
|
interface DraftSource {
|
||||||
|
/** Stable client-side id; not persisted. */
|
||||||
|
uid: string;
|
||||||
|
kind: DraftSourceKind;
|
||||||
|
name: string;
|
||||||
|
/** Only set when kind === "file". */
|
||||||
|
file?: File;
|
||||||
|
/** Only set when kind === "url". */
|
||||||
|
url?: string;
|
||||||
|
/** Only set when kind === "text". */
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaToggleState {
|
||||||
|
/** Generate narration audio (Polly / ElevenLabs) for listening +
|
||||||
|
* speaking materials right after each week's materials are produced. */
|
||||||
|
audio: boolean;
|
||||||
|
/** Generate DALL-E images for reading texts, listening scripts, and
|
||||||
|
* vocabulary lists. Bound to the per-plan image budget. */
|
||||||
|
image: boolean;
|
||||||
|
/** Compose audio + image into a slideshow MP4 with ffmpeg. Slowest
|
||||||
|
* option; off by default. */
|
||||||
|
video: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface CoursePlanWizardState {
|
interface CoursePlanWizardState {
|
||||||
title: string;
|
title: string;
|
||||||
cefr_level: string;
|
cefr_level: string;
|
||||||
@@ -48,6 +85,8 @@ interface CoursePlanWizardState {
|
|||||||
grammar_focus: string[];
|
grammar_focus: string[];
|
||||||
resources: string[];
|
resources: string[];
|
||||||
notes: string;
|
notes: string;
|
||||||
|
sources: DraftSource[];
|
||||||
|
media: MediaToggleState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CEFR_OPTIONS = [
|
const CEFR_OPTIONS = [
|
||||||
@@ -183,6 +222,17 @@ export default function CoursePlanWizard() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "sources",
|
||||||
|
titleKey: "coursePlan.wizard.steps.sources",
|
||||||
|
descriptionKey: "coursePlan.wizard.steps.sourcesDesc",
|
||||||
|
render: ({ state, update }) => (
|
||||||
|
<SourcesStep
|
||||||
|
sources={state.sources}
|
||||||
|
onChange={(sources) => update({ sources })}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "scope",
|
id: "scope",
|
||||||
titleKey: "coursePlan.wizard.steps.scope",
|
titleKey: "coursePlan.wizard.steps.scope",
|
||||||
@@ -214,6 +264,17 @@ export default function CoursePlanWizard() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "media",
|
||||||
|
titleKey: "coursePlan.wizard.steps.media",
|
||||||
|
descriptionKey: "coursePlan.wizard.steps.mediaDesc",
|
||||||
|
render: ({ state, update }) => (
|
||||||
|
<MediaStep
|
||||||
|
media={state.media}
|
||||||
|
onChange={(media) => update({ media })}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "review",
|
id: "review",
|
||||||
titleKey: "coursePlan.wizard.steps.review",
|
titleKey: "coursePlan.wizard.steps.review",
|
||||||
@@ -257,6 +318,26 @@ export default function CoursePlanWizard() {
|
|||||||
label={t("coursePlan.wizard.fields.notes")}
|
label={t("coursePlan.wizard.fields.notes")}
|
||||||
value={state.notes || "—"}
|
value={state.notes || "—"}
|
||||||
/>
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label={t("coursePlan.wizard.steps.sources")}
|
||||||
|
value={
|
||||||
|
state.sources.length
|
||||||
|
? t("coursePlan.wizard.review.sourcesCount", {
|
||||||
|
count: state.sources.length,
|
||||||
|
})
|
||||||
|
: t("coursePlan.wizard.review.noSources")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label={t("coursePlan.wizard.steps.media")}
|
||||||
|
value={[
|
||||||
|
state.media.audio ? t("coursePlan.media.audio") : null,
|
||||||
|
state.media.image ? t("coursePlan.media.image") : null,
|
||||||
|
state.media.video ? t("coursePlan.media.video") : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ") || t("coursePlan.wizard.review.mediaNone")}
|
||||||
|
/>
|
||||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||||
{t("coursePlan.wizard.reviewHint")}
|
{t("coursePlan.wizard.reviewHint")}
|
||||||
</div>
|
</div>
|
||||||
@@ -283,9 +364,11 @@ export default function CoursePlanWizard() {
|
|||||||
grammar_focus: [],
|
grammar_focus: [],
|
||||||
resources: [],
|
resources: [],
|
||||||
notes: "",
|
notes: "",
|
||||||
|
sources: [],
|
||||||
|
media: { audio: true, image: true, video: false },
|
||||||
}}
|
}}
|
||||||
onFinish={async (state) => {
|
onFinish={async (state) => {
|
||||||
await mutation.mutateAsync({
|
const resp = await mutation.mutateAsync({
|
||||||
title: state.title.trim(),
|
title: state.title.trim(),
|
||||||
cefr_level: state.cefr_level,
|
cefr_level: state.cefr_level,
|
||||||
total_weeks: state.total_weeks,
|
total_weeks: state.total_weeks,
|
||||||
@@ -298,11 +381,284 @@ export default function CoursePlanWizard() {
|
|||||||
resources: state.resources.length ? state.resources : undefined,
|
resources: state.resources.length ? state.resources : undefined,
|
||||||
notes: state.notes.trim() || undefined,
|
notes: state.notes.trim() || undefined,
|
||||||
});
|
});
|
||||||
|
const planId = resp?.data?.id;
|
||||||
|
if (planId && state.sources.length) {
|
||||||
|
// Best-effort upload — we keep going even if a single one fails so
|
||||||
|
// the user lands on the detail page with whatever did make it in.
|
||||||
|
for (const src of state.sources) {
|
||||||
|
try {
|
||||||
|
if (src.kind === "file" && src.file) {
|
||||||
|
await coursePlanService.uploadSource(planId, src.file, {
|
||||||
|
name: src.name || src.file.name,
|
||||||
|
});
|
||||||
|
} else if (src.kind === "url" && src.url) {
|
||||||
|
await coursePlanService.createSource(planId, {
|
||||||
|
kind: "url",
|
||||||
|
url: src.url,
|
||||||
|
name: src.name || src.url,
|
||||||
|
});
|
||||||
|
} else if (src.kind === "text" && src.text) {
|
||||||
|
await coursePlanService.createSource(planId, {
|
||||||
|
kind: "text",
|
||||||
|
inline_text: src.text,
|
||||||
|
name: src.name || t("coursePlan.sourceKind.text"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
describeApiError(err, t("coursePlan.sources.uploadFailed", {
|
||||||
|
name: src.name || src.kind,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function genUid() {
|
||||||
|
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourcesStep({
|
||||||
|
sources,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
sources: DraftSource[];
|
||||||
|
onChange: (next: DraftSource[]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [draftUrl, setDraftUrl] = useState("");
|
||||||
|
const [draftText, setDraftText] = useState("");
|
||||||
|
|
||||||
|
const addFiles = (files: FileList | null) => {
|
||||||
|
if (!files || !files.length) return;
|
||||||
|
const additions: DraftSource[] = [];
|
||||||
|
for (const f of Array.from(files)) {
|
||||||
|
additions.push({
|
||||||
|
uid: genUid(),
|
||||||
|
kind: "file",
|
||||||
|
name: f.name,
|
||||||
|
file: f,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onChange([...sources, ...additions]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUrl = () => {
|
||||||
|
const url = draftUrl.trim();
|
||||||
|
if (!url) return;
|
||||||
|
onChange([...sources, { uid: genUid(), kind: "url", name: url, url }]);
|
||||||
|
setDraftUrl("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const addText = () => {
|
||||||
|
const text = draftText.trim();
|
||||||
|
if (!text) return;
|
||||||
|
onChange([
|
||||||
|
...sources,
|
||||||
|
{
|
||||||
|
uid: genUid(),
|
||||||
|
kind: "text",
|
||||||
|
name: text.slice(0, 60).replace(/\s+/g, " "),
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setDraftText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (uid: string) => onChange(sources.filter((s) => s.uid !== uid));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
|
||||||
|
<FileText className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||||
|
<p className="mt-2 text-sm font-medium">
|
||||||
|
{t("coursePlan.sources.dropTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("coursePlan.sources.dropHint")}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.rtf"
|
||||||
|
onChange={(e) => {
|
||||||
|
addFiles(e.currentTarget.files);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="mr-1 h-4 w-4" />
|
||||||
|
{t("coursePlan.sources.uploadFiles")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label>{t("coursePlan.sources.urlLabel")}</Label>
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={draftUrl}
|
||||||
|
onChange={(e) => setDraftUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/article"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addUrl();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addUrl}
|
||||||
|
disabled={!draftUrl.trim()}
|
||||||
|
>
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{t("coursePlan.sources.textLabel")}</Label>
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
value={draftText}
|
||||||
|
onChange={(e) => setDraftText(e.target.value)}
|
||||||
|
placeholder={t("coursePlan.sources.textPlaceholder")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addText}
|
||||||
|
disabled={!draftText.trim()}
|
||||||
|
className="self-start"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sources.length > 0 && (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<div className="px-3 py-2 border-b bg-muted/30 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t("coursePlan.sources.collected", { count: sources.length })}
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y">
|
||||||
|
{sources.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.uid}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<Badge variant="outline" className="capitalize text-[10px]">
|
||||||
|
{s.kind}
|
||||||
|
</Badge>
|
||||||
|
<span className="flex-1 truncate">{s.name}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => remove(s.uid)}
|
||||||
|
aria-label={t("common.remove", "Remove")}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaStep({
|
||||||
|
media,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
media: MediaToggleState;
|
||||||
|
onChange: (next: MediaToggleState) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<MediaToggleCard
|
||||||
|
icon={Music}
|
||||||
|
title={t("coursePlan.media.audioTitle")}
|
||||||
|
description={t("coursePlan.media.audioDesc")}
|
||||||
|
checked={media.audio}
|
||||||
|
onToggle={(v) => onChange({ ...media, audio: v })}
|
||||||
|
/>
|
||||||
|
<MediaToggleCard
|
||||||
|
icon={ImageIcon}
|
||||||
|
title={t("coursePlan.media.imageTitle")}
|
||||||
|
description={t("coursePlan.media.imageDesc")}
|
||||||
|
checked={media.image}
|
||||||
|
onToggle={(v) => onChange({ ...media, image: v })}
|
||||||
|
/>
|
||||||
|
<MediaToggleCard
|
||||||
|
icon={Video}
|
||||||
|
title={t("coursePlan.media.videoTitle")}
|
||||||
|
description={t("coursePlan.media.videoDesc")}
|
||||||
|
checked={media.video}
|
||||||
|
onToggle={(v) => onChange({ ...media, video: v })}
|
||||||
|
/>
|
||||||
|
<div className="sm:col-span-3 text-xs text-muted-foreground flex items-center gap-2">
|
||||||
|
<Mic className="h-3.5 w-3.5" />
|
||||||
|
{t("coursePlan.media.hint")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaToggleCard({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
icon: typeof Music;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(!checked)}
|
||||||
|
className={[
|
||||||
|
"rounded-md border p-3 text-left transition-colors",
|
||||||
|
checked
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:bg-muted/40",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="h-4 w-4 text-primary" />
|
||||||
|
<div className="font-medium text-sm">{title}</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
|||||||
311
frontend/src/pages/student/StudentCoursePlanDetail.tsx
Normal file
311
frontend/src/pages/student/StudentCoursePlanDetail.tsx
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
BookOpen,
|
||||||
|
Calendar,
|
||||||
|
ClipboardList,
|
||||||
|
Headphones,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Library,
|
||||||
|
MessageSquare,
|
||||||
|
Mic,
|
||||||
|
Music,
|
||||||
|
PenSquare,
|
||||||
|
Sparkles,
|
||||||
|
Type,
|
||||||
|
Video,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { coursePlanService } from "@/services/coursePlan.service";
|
||||||
|
import { describeApiError } from "@/lib/api-client";
|
||||||
|
import type {
|
||||||
|
CoursePlan,
|
||||||
|
CoursePlanMaterial,
|
||||||
|
CoursePlanMedia,
|
||||||
|
CoursePlanWeek,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
|
const SKILL_ICONS: Record<string, LucideIcon> = {
|
||||||
|
reading: BookOpen,
|
||||||
|
writing: PenSquare,
|
||||||
|
listening: Headphones,
|
||||||
|
speaking: Mic,
|
||||||
|
grammar: Type,
|
||||||
|
vocabulary: Library,
|
||||||
|
integrated: MessageSquare,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Student detail view for an assigned AI course plan.
|
||||||
|
*
|
||||||
|
* Server-side authorisation: the `/api/student/course-plans/:id` endpoint
|
||||||
|
* returns 403 unless the current user is in the plan's assignment list,
|
||||||
|
* so we can render unconditionally as soon as the query resolves.
|
||||||
|
*
|
||||||
|
* The page is intentionally read-only — students can play audio, view
|
||||||
|
* images, and watch video, but they can't (re)generate content.
|
||||||
|
*/
|
||||||
|
export default function StudentCoursePlanDetail() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { planId: planIdRaw } = useParams<{ planId: string }>();
|
||||||
|
const planId = Number(planIdRaw);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
|
queryKey: ["student-course-plan", planId],
|
||||||
|
queryFn: () => coursePlanService.studentGet(planId),
|
||||||
|
enabled: Number.isFinite(planId) && planId > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const plan = data?.data as CoursePlan | undefined;
|
||||||
|
|
||||||
|
const materialsByWeek = useMemo(() => {
|
||||||
|
const map = new Map<number, CoursePlanMaterial[]>();
|
||||||
|
if (!plan?.materials) return map;
|
||||||
|
for (const m of plan.materials) {
|
||||||
|
const list = map.get(m.week_number) ?? [];
|
||||||
|
list.push(m);
|
||||||
|
map.set(m.week_number, list);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [plan?.materials]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
|
||||||
|
<Link to="/student/course-plans" className="flex items-center gap-1">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
{t("coursePlan.student.backToList")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-20" />
|
||||||
|
<Skeleton className="h-40" />
|
||||||
|
<Skeleton className="h-40" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{describeApiError(error, t("coursePlan.loadFailed"))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plan && (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-2xl">{plan.name}</CardTitle>
|
||||||
|
{plan.description && (
|
||||||
|
<CardDescription className="max-w-3xl">
|
||||||
|
{plan.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Badge variant="secondary" className="uppercase">
|
||||||
|
{plan.cefr_level}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t("coursePlan.hoursPerWeek", {
|
||||||
|
count: plan.contact_hours_per_week,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{plan.assignment && (
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
{plan.assignment.due_date
|
||||||
|
? t("coursePlan.student.due", { date: plan.assignment.due_date })
|
||||||
|
: t("coursePlan.student.noDue")}
|
||||||
|
{plan.assignment.assigned_by_name && (
|
||||||
|
<span>
|
||||||
|
·{" "}
|
||||||
|
{t("coursePlan.student.assignedBy", {
|
||||||
|
name: plan.assignment.assigned_by_name,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{plan.assignment?.message && (
|
||||||
|
<p className="text-sm bg-muted/40 rounded-md px-3 py-2 mt-2">
|
||||||
|
{plan.assignment.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{plan.objectives.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<ClipboardList className="h-4 w-4 text-primary" />
|
||||||
|
{t("coursePlan.sections.objectives")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||||
|
{plan.objectives.map((o, i) => (
|
||||||
|
<li key={i}>{o}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plan.weeks && plan.weeks.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
|
{t("coursePlan.sections.delivery")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t("coursePlan.deliveryHint")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Accordion type="multiple" className="w-full">
|
||||||
|
{plan.weeks.map((w) => (
|
||||||
|
<StudentWeek
|
||||||
|
key={w.id}
|
||||||
|
week={w}
|
||||||
|
materials={materialsByWeek.get(w.week_number) ?? []}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StudentWeek({
|
||||||
|
week,
|
||||||
|
materials,
|
||||||
|
}: {
|
||||||
|
week: CoursePlanWeek;
|
||||||
|
materials: CoursePlanMaterial[];
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<AccordionItem value={String(week.week_number)}>
|
||||||
|
<AccordionTrigger className="text-left">
|
||||||
|
<div className="flex-1 flex items-center gap-3 min-w-0">
|
||||||
|
<Badge variant="outline" className="shrink-0">
|
||||||
|
{t("coursePlan.weekN", { n: week.week_number })}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{week.focus || week.unit || "—"}
|
||||||
|
</div>
|
||||||
|
{week.date_label && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{week.date_label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3">
|
||||||
|
{materials.length === 0 && (
|
||||||
|
<p className="text-sm italic text-muted-foreground">
|
||||||
|
{t("coursePlan.media.noMedia")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{materials.map((m) => (
|
||||||
|
<StudentMaterial key={m.id} material={m} />
|
||||||
|
))}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StudentMaterial({ material }: { material: CoursePlanMaterial }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Icon className="h-4 w-4 text-primary" />
|
||||||
|
<CardTitle className="text-base flex-1 min-w-0">
|
||||||
|
{material.title}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{t(
|
||||||
|
`coursePlan.materialType.${material.material_type}`,
|
||||||
|
material.material_type,
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{material.summary && <CardDescription>{material.summary}</CardDescription>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{(material.media ?? []).map((m) => (
|
||||||
|
<StudentMediaTile key={m.id} media={m} />
|
||||||
|
))}
|
||||||
|
<pre className="whitespace-pre-wrap text-xs font-mono bg-muted/40 rounded-md p-3 max-h-80 overflow-auto">
|
||||||
|
{material.body_text || JSON.stringify(material.body, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StudentMediaTile({ media }: { media: CoursePlanMedia }) {
|
||||||
|
const url = media.download_url || "";
|
||||||
|
if (!url) return null;
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{media.kind === "audio" && <Music className="h-4 w-4" />}
|
||||||
|
{media.kind === "image" && <ImageIcon className="h-4 w-4" />}
|
||||||
|
{media.kind === "video" && <Video className="h-4 w-4" />}
|
||||||
|
<span className="capitalize text-xs text-muted-foreground">
|
||||||
|
{media.kind}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{media.kind === "audio" && <audio src={url} controls className="w-full" />}
|
||||||
|
{media.kind === "image" && (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={media.title}
|
||||||
|
className="w-full rounded-md max-h-72 object-contain bg-muted/30"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{media.kind === "video" && (
|
||||||
|
<video src={url} controls className="w-full rounded-md max-h-72" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
frontend/src/pages/student/StudentCoursePlans.tsx
Normal file
147
frontend/src/pages/student/StudentCoursePlans.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Calendar,
|
||||||
|
GraduationCap,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { coursePlanService } from "@/services/coursePlan.service";
|
||||||
|
import { describeApiError } from "@/lib/api-client";
|
||||||
|
import type { CoursePlan } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Student-facing list of AI course plans assigned to the current user.
|
||||||
|
*
|
||||||
|
* Reads from `GET /api/student/course-plans`, which only returns plans
|
||||||
|
* the user has been linked to either directly (Phase D `students` mode)
|
||||||
|
* or via the batch they're enrolled in. The card itself is intentionally
|
||||||
|
* lightweight: tap → drill into `/student/course-plans/:id` for the full
|
||||||
|
* weekly plan and embedded media.
|
||||||
|
*/
|
||||||
|
export default function StudentCoursePlans() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
|
queryKey: ["student-course-plans"],
|
||||||
|
queryFn: () => coursePlanService.studentList(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
<h1 className="text-2xl font-bold">{t("coursePlan.student.listTitle")}</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1 max-w-2xl">
|
||||||
|
{t("coursePlan.student.listSubtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-44 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{describeApiError(error, t("coursePlan.loadFailed"))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && items.length === 0 && (
|
||||||
|
<div className="text-center py-12 border rounded-lg bg-muted/20">
|
||||||
|
<GraduationCap className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t("coursePlan.student.empty")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((plan) => (
|
||||||
|
<PlanCard key={plan.id} plan={plan} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanCard({ plan }: { plan: CoursePlan }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const a = plan.assignment;
|
||||||
|
return (
|
||||||
|
<Card className="hover:shadow-md transition-shadow flex flex-col">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<CardTitle className="text-base flex-1 min-w-0 line-clamp-2">
|
||||||
|
{plan.name}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="secondary" className="uppercase shrink-0">
|
||||||
|
{plan.cefr_level}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{plan.description && (
|
||||||
|
<CardDescription className="line-clamp-2">
|
||||||
|
{plan.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t("coursePlan.hoursPerWeek", {
|
||||||
|
count: plan.contact_hours_per_week,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{a && (
|
||||||
|
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||||
|
{a.assigned_by_name && (
|
||||||
|
<div>
|
||||||
|
{t("coursePlan.student.assignedBy", { name: a.assigned_by_name })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{a.due_date
|
||||||
|
? t("coursePlan.student.due", { date: a.due_date })
|
||||||
|
: t("coursePlan.student.noDue")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-auto pt-2">
|
||||||
|
<Button asChild size="sm" className="w-full">
|
||||||
|
<Link to={`/student/course-plans/${plan.id}`}>
|
||||||
|
{t("coursePlan.student.open")}
|
||||||
|
<ArrowRight className="ml-1 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import type {
|
import type {
|
||||||
CoursePlan,
|
CoursePlan,
|
||||||
|
CoursePlanAssignment,
|
||||||
|
CoursePlanDeliverables,
|
||||||
CoursePlanGenerateBrief,
|
CoursePlanGenerateBrief,
|
||||||
CoursePlanMaterial,
|
CoursePlanMaterial,
|
||||||
|
CoursePlanMedia,
|
||||||
|
CoursePlanSource,
|
||||||
|
CoursePlanSourceKind,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,6 +17,9 @@ import type {
|
|||||||
* `backend/custom_addons/encoach_ai_course/controllers/course_plan.py`.
|
* `backend/custom_addons/encoach_ai_course/controllers/course_plan.py`.
|
||||||
*/
|
*/
|
||||||
export const coursePlanService = {
|
export const coursePlanService = {
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Plan lifecycle
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
async list(params?: {
|
async list(params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
@@ -45,4 +53,166 @@ export const coursePlanService = {
|
|||||||
): Promise<{ items: CoursePlanMaterial[]; count: number }> {
|
): Promise<{ items: CoursePlanMaterial[]; count: number }> {
|
||||||
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
|
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Phase A — Sources
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
async listSources(planId: number): Promise<{ items: CoursePlanSource[]; count: number }> {
|
||||||
|
return api.get(`/ai/course-plan/${planId}/sources`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createSource(
|
||||||
|
planId: number,
|
||||||
|
payload:
|
||||||
|
| { kind: "url"; url: string; name?: string; auto_index?: boolean }
|
||||||
|
| { kind: "text"; inline_text: string; name?: string; auto_index?: boolean },
|
||||||
|
): Promise<{ data: CoursePlanSource }> {
|
||||||
|
return api.post(`/ai/course-plan/${planId}/sources`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadSource(
|
||||||
|
planId: number,
|
||||||
|
file: File,
|
||||||
|
opts?: { name?: string; auto_index?: boolean },
|
||||||
|
): Promise<{ data: CoursePlanSource }> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
fd.append("kind", "file" satisfies CoursePlanSourceKind);
|
||||||
|
if (opts?.name) fd.append("name", opts.name);
|
||||||
|
if (opts?.auto_index !== undefined) {
|
||||||
|
fd.append("auto_index", opts.auto_index ? "1" : "0");
|
||||||
|
}
|
||||||
|
return api.upload(`/ai/course-plan/${planId}/sources`, fd);
|
||||||
|
},
|
||||||
|
|
||||||
|
async reindexSource(
|
||||||
|
planId: number,
|
||||||
|
sourceId: number,
|
||||||
|
): Promise<{ data: CoursePlanSource }> {
|
||||||
|
return api.post(
|
||||||
|
`/ai/course-plan/${planId}/sources/${sourceId}/index`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteSource(
|
||||||
|
planId: number,
|
||||||
|
sourceId: number,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
return api.delete(`/ai/course-plan/${planId}/sources/${sourceId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Phase B — Deliverables / progress
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
async deliverables(planId: number): Promise<CoursePlanDeliverables> {
|
||||||
|
return api.get(`/ai/course-plan/${planId}/deliverables`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Phase C — Multimedia
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
async listMaterialMedia(
|
||||||
|
materialId: number,
|
||||||
|
): Promise<{ items: CoursePlanMedia[]; count: number }> {
|
||||||
|
return api.get(`/ai/course-plan/material/${materialId}/media`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateAudio(
|
||||||
|
materialId: number,
|
||||||
|
payload?: {
|
||||||
|
voice?: string;
|
||||||
|
language?: string;
|
||||||
|
gender?: "male" | "female";
|
||||||
|
provider?: "polly" | "elevenlabs";
|
||||||
|
},
|
||||||
|
): Promise<{ data: CoursePlanMedia }> {
|
||||||
|
return api.post(
|
||||||
|
`/ai/course-plan/material/${materialId}/media/audio`,
|
||||||
|
payload ?? {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateImage(
|
||||||
|
materialId: number,
|
||||||
|
payload?: {
|
||||||
|
prompt?: string;
|
||||||
|
size?: "1024x1024" | "1792x1024" | "1024x1792";
|
||||||
|
style?: "natural" | "vivid";
|
||||||
|
quality?: "standard" | "hd";
|
||||||
|
},
|
||||||
|
): Promise<{ data: CoursePlanMedia }> {
|
||||||
|
return api.post(
|
||||||
|
`/ai/course-plan/material/${materialId}/media/image`,
|
||||||
|
payload ?? {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async composeVideo(materialId: number): Promise<{ data: CoursePlanMedia }> {
|
||||||
|
return api.post(
|
||||||
|
`/ai/course-plan/material/${materialId}/media/video`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteMedia(mediaId: number): Promise<{ success: boolean }> {
|
||||||
|
return api.delete(`/ai/course-plan/media/${mediaId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateWeekMedia(
|
||||||
|
planId: number,
|
||||||
|
weekNumber: number,
|
||||||
|
payload?: { kinds?: Array<"audio" | "image" | "video"> },
|
||||||
|
): Promise<{ items: Array<CoursePlanMedia | { error: string; material_id: number }>; count: number }> {
|
||||||
|
return api.post(
|
||||||
|
`/ai/course-plan/${planId}/weeks/${weekNumber}/media`,
|
||||||
|
payload ?? {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Phase D — Assignments
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
async listAssignments(
|
||||||
|
planId: number,
|
||||||
|
): Promise<{ items: CoursePlanAssignment[]; count: number }> {
|
||||||
|
return api.get(`/ai/course-plan/${planId}/assignments`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createAssignment(
|
||||||
|
planId: number,
|
||||||
|
payload:
|
||||||
|
| {
|
||||||
|
mode: "batch";
|
||||||
|
batch_id: number;
|
||||||
|
due_date?: string | null;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: "students";
|
||||||
|
student_user_ids: number[];
|
||||||
|
due_date?: string | null;
|
||||||
|
message?: string;
|
||||||
|
},
|
||||||
|
): Promise<{ data: CoursePlanAssignment }> {
|
||||||
|
return api.post(`/ai/course-plan/${planId}/assignments`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAssignment(
|
||||||
|
planId: number,
|
||||||
|
assignmentId: number,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
return api.delete(
|
||||||
|
`/ai/course-plan/${planId}/assignments/${assignmentId}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Phase E — Student-side
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
async studentList(): Promise<{ items: CoursePlan[]; count: number }> {
|
||||||
|
return api.get("/student/course-plans");
|
||||||
|
},
|
||||||
|
|
||||||
|
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
|
||||||
|
return api.get(`/student/course-plans/${planId}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,6 +81,142 @@ export interface CoursePlanMaterial {
|
|||||||
/** Loose shape: depends on material_type. */
|
/** Loose shape: depends on material_type. */
|
||||||
body: Record<string, unknown>;
|
body: Record<string, unknown>;
|
||||||
body_text: string;
|
body_text: string;
|
||||||
|
media?: CoursePlanMedia[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase A — Reference sources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CoursePlanSourceKind = "file" | "url" | "text";
|
||||||
|
export type CoursePlanSourceStatus = "pending" | "indexing" | "indexed" | "failed";
|
||||||
|
|
||||||
|
export interface CoursePlanSource {
|
||||||
|
id: number;
|
||||||
|
plan_id: number;
|
||||||
|
name: string;
|
||||||
|
kind: CoursePlanSourceKind;
|
||||||
|
file_name: string;
|
||||||
|
mime_type: string;
|
||||||
|
url: string;
|
||||||
|
has_inline_text: boolean;
|
||||||
|
status: CoursePlanSourceStatus;
|
||||||
|
error: string;
|
||||||
|
chunks_count: number;
|
||||||
|
extracted_chars: number;
|
||||||
|
indexed_at: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase B — Deliverables preview / progress
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type DeliverableStatus = "planned" | "generated" | "ready";
|
||||||
|
|
||||||
|
export interface CoursePlanDeliverable {
|
||||||
|
skill: string;
|
||||||
|
material_type: string;
|
||||||
|
material_id: number | null;
|
||||||
|
title: string;
|
||||||
|
status: DeliverableStatus;
|
||||||
|
media: Array<{
|
||||||
|
id: number;
|
||||||
|
kind: "audio" | "image" | "video";
|
||||||
|
status: string;
|
||||||
|
provider: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoursePlanDeliverablesWeek {
|
||||||
|
week_number: number;
|
||||||
|
date_label: string;
|
||||||
|
unit: string;
|
||||||
|
focus: string;
|
||||||
|
items_total: number;
|
||||||
|
deliverables: CoursePlanDeliverable[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoursePlanDeliverables {
|
||||||
|
summary: {
|
||||||
|
total: number;
|
||||||
|
planned: number;
|
||||||
|
generated: number;
|
||||||
|
ready: number;
|
||||||
|
percent_ready: number;
|
||||||
|
media: {
|
||||||
|
audio: number;
|
||||||
|
image: number;
|
||||||
|
video: number;
|
||||||
|
audio_ready: number;
|
||||||
|
image_ready: number;
|
||||||
|
video_ready: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
weeks: CoursePlanDeliverablesWeek[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase C — Multimedia
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CoursePlanMediaKind = "audio" | "image" | "video";
|
||||||
|
export type CoursePlanMediaStatus = "queued" | "generating" | "ready" | "failed";
|
||||||
|
export type CoursePlanMediaProvider =
|
||||||
|
| "polly"
|
||||||
|
| "elevenlabs"
|
||||||
|
| "openai_image"
|
||||||
|
| "ffmpeg"
|
||||||
|
| "elai"
|
||||||
|
| "manual";
|
||||||
|
|
||||||
|
export interface CoursePlanMedia {
|
||||||
|
id: number;
|
||||||
|
plan_id: number;
|
||||||
|
week_id: number | null;
|
||||||
|
material_id: number | null;
|
||||||
|
kind: CoursePlanMediaKind;
|
||||||
|
provider: CoursePlanMediaProvider | string;
|
||||||
|
title: string;
|
||||||
|
voice: string;
|
||||||
|
language: string;
|
||||||
|
style: string;
|
||||||
|
mime_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
duration_seconds: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
attachment_id: number | null;
|
||||||
|
download_url: string;
|
||||||
|
status: CoursePlanMediaStatus;
|
||||||
|
error: string;
|
||||||
|
cost_cents: number;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase D — Assignments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CoursePlanAssignmentMode = "batch" | "students";
|
||||||
|
export type CoursePlanAssignmentState = "active" | "archived";
|
||||||
|
|
||||||
|
export interface CoursePlanAssignment {
|
||||||
|
id: number;
|
||||||
|
plan_id: number;
|
||||||
|
plan_name: string;
|
||||||
|
mode: CoursePlanAssignmentMode;
|
||||||
|
batch_id: number | null;
|
||||||
|
batch_name: string;
|
||||||
|
student_user_ids: number[];
|
||||||
|
student_user_names: string[];
|
||||||
|
student_count: number;
|
||||||
|
assigned_by_id: number | null;
|
||||||
|
assigned_by_name: string;
|
||||||
|
due_date: string | null;
|
||||||
|
message: string;
|
||||||
|
state: CoursePlanAssignmentState;
|
||||||
|
created_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoursePlan {
|
export interface CoursePlan {
|
||||||
@@ -101,9 +237,17 @@ export interface CoursePlan {
|
|||||||
resources: CoursePlanResource[];
|
resources: CoursePlanResource[];
|
||||||
week_count: number;
|
week_count: number;
|
||||||
material_count: number;
|
material_count: number;
|
||||||
|
source_count?: number;
|
||||||
|
media_count?: number;
|
||||||
|
assignment_count?: number;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
weeks?: CoursePlanWeek[];
|
weeks?: CoursePlanWeek[];
|
||||||
materials?: CoursePlanMaterial[];
|
materials?: CoursePlanMaterial[];
|
||||||
|
sources?: CoursePlanSource[];
|
||||||
|
media?: CoursePlanMedia[];
|
||||||
|
assignments?: CoursePlanAssignment[];
|
||||||
|
/** Present on the student-list endpoint payload. */
|
||||||
|
assignment?: CoursePlanAssignment;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoursePlanGenerateBrief {
|
export interface CoursePlanGenerateBrief {
|
||||||
|
|||||||
181
smoke_course_plan.py
Normal file
181
smoke_course_plan.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""End-to-end smoke test for the AI course-plan feature.
|
||||||
|
|
||||||
|
Run via: odoo-bin shell -c odoo.conf -d encoach_v2 < smoke_course_plan.py
|
||||||
|
|
||||||
|
Tests Phases A-E of the LangGraph-based course-plan pipeline:
|
||||||
|
A. Sources / RAG (encoach.course.plan.source + SourceIndexer)
|
||||||
|
B. Deliverables (services.deliverables.compute_deliverables)
|
||||||
|
C. Multimedia (encoach.course.plan.media + MediaService)
|
||||||
|
D. Assignments (encoach.course.plan.assignment)
|
||||||
|
E. Student visibility (assignment.expand_user_ids)
|
||||||
|
|
||||||
|
External-API steps (DALL-E, Polly) tolerate missing credentials by
|
||||||
|
checking ``status == 'failed'`` and reporting the provider error rather
|
||||||
|
than failing the whole script.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def hr(title):
|
||||||
|
print(f"\n{'='*70}\n{title}\n{'='*70}")
|
||||||
|
|
||||||
|
|
||||||
|
def fail(msg):
|
||||||
|
print(f" FAIL {msg}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def ok(msg):
|
||||||
|
print(f" PASS {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
hr("0. Locate fixtures")
|
||||||
|
Plan = env['encoach.course.plan'].sudo()
|
||||||
|
plan = Plan.search([], order='id desc', limit=1)
|
||||||
|
if not plan:
|
||||||
|
fail("No course plan in DB; seed one first")
|
||||||
|
print(f" using plan #{plan.id}: {plan.name!r} (CEFR {plan.cefr_level})")
|
||||||
|
|
||||||
|
Users = env['res.users'].sudo()
|
||||||
|
sarah = Users.search([('login', '=', 'sarah@encoach.test')], limit=1)
|
||||||
|
if not sarah:
|
||||||
|
fail("sarah@encoach.test not seeded")
|
||||||
|
print(f" using student user #{sarah.id}: {sarah.name}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Phase A
|
||||||
|
hr("Phase A — Sources & RAG indexing")
|
||||||
|
|
||||||
|
Source = env['encoach.course.plan.source'].sudo()
|
||||||
|
src = Source.create({
|
||||||
|
'plan_id': plan.id,
|
||||||
|
'name': 'Smoke test inline source',
|
||||||
|
'kind': 'text',
|
||||||
|
'inline_text': (
|
||||||
|
'In Week 1 of the General English course we focus on present '
|
||||||
|
'simple and present continuous. Reading texts target daily '
|
||||||
|
'routines, family, and study habits at CEFR A2-B1 level. '
|
||||||
|
'Listening scripts feature 3-minute monologues on student life '
|
||||||
|
'with comprehension questions covering main idea and detail.'
|
||||||
|
),
|
||||||
|
'mime_type': 'text/plain',
|
||||||
|
})
|
||||||
|
ok(f"created source #{src.id}")
|
||||||
|
|
||||||
|
from odoo.addons.encoach_ai_course.services.source_indexer import (
|
||||||
|
SourceIndexer,
|
||||||
|
)
|
||||||
|
result = SourceIndexer(env).index(src)
|
||||||
|
print(f" index result: {result}")
|
||||||
|
src.invalidate_recordset()
|
||||||
|
if src.status == 'indexed' and src.chunks_count > 0:
|
||||||
|
ok(f"indexed: {src.chunks_count} chunks, {src.extracted_chars} chars")
|
||||||
|
elif src.status == 'failed':
|
||||||
|
print(f" WARN indexing failed (likely no embedding API key configured): "
|
||||||
|
f"{src.error}")
|
||||||
|
else:
|
||||||
|
print(f" WARN unexpected status: {src.status}")
|
||||||
|
|
||||||
|
env.cr.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Phase B
|
||||||
|
hr("Phase B — Deliverables computation")
|
||||||
|
from odoo.addons.encoach_ai_course.services.deliverables import (
|
||||||
|
compute_deliverables,
|
||||||
|
)
|
||||||
|
deliv = compute_deliverables(plan)
|
||||||
|
summary = deliv['summary']
|
||||||
|
print(f" summary: total={summary['total']} planned={summary['planned']} "
|
||||||
|
f"generated={summary['generated']} ready={summary['ready']} "
|
||||||
|
f"%={summary['percent_ready']}")
|
||||||
|
print(f" media (current/ready): "
|
||||||
|
f"audio {summary['media']['audio']}/{summary['media']['audio_ready']} "
|
||||||
|
f"image {summary['media']['image']}/{summary['media']['image_ready']} "
|
||||||
|
f"video {summary['media']['video']}/{summary['media']['video_ready']}")
|
||||||
|
ok(f"compute_deliverables returned {len(deliv['weeks'])} weeks")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Phase C
|
||||||
|
hr("Phase C — Multimedia generation (audio + image)")
|
||||||
|
from odoo.addons.encoach_ai_course.services.media_service import MediaService
|
||||||
|
mat_listening = plan.material_ids.filtered(
|
||||||
|
lambda m: m.material_type == 'listening_script'
|
||||||
|
)[:1]
|
||||||
|
mat_reading = plan.material_ids.filtered(
|
||||||
|
lambda m: m.material_type == 'reading_text'
|
||||||
|
)[:1]
|
||||||
|
|
||||||
|
svc = MediaService(env)
|
||||||
|
if mat_listening:
|
||||||
|
audio_media = svc.synthesize_audio(mat_listening, language='en-GB')
|
||||||
|
print(f" audio media #{audio_media.id} status={audio_media.status} "
|
||||||
|
f"err={(audio_media.error or '')[:120]!r}")
|
||||||
|
if audio_media.status == 'ready':
|
||||||
|
ok(f"audio bytes={audio_media.size_bytes}")
|
||||||
|
else:
|
||||||
|
print(f" WARN audio not ready (provider may need creds)")
|
||||||
|
else:
|
||||||
|
print(" SKIP no listening_script material on plan")
|
||||||
|
|
||||||
|
if mat_reading:
|
||||||
|
image_media = svc.generate_image(mat_reading, size='1024x1024')
|
||||||
|
print(f" image media #{image_media.id} status={image_media.status} "
|
||||||
|
f"err={(image_media.error or '')[:120]!r}")
|
||||||
|
if image_media.status == 'ready':
|
||||||
|
ok(f"image bytes={image_media.size_bytes}")
|
||||||
|
else:
|
||||||
|
print(f" WARN image not ready (provider may need creds)")
|
||||||
|
else:
|
||||||
|
print(" SKIP no reading_text material on plan")
|
||||||
|
|
||||||
|
env.cr.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Phase D
|
||||||
|
hr("Phase D — Assignment creation")
|
||||||
|
Assignment = env['encoach.course.plan.assignment'].sudo()
|
||||||
|
existing = Assignment.search([
|
||||||
|
('plan_id', '=', plan.id),
|
||||||
|
('mode', '=', 'students'),
|
||||||
|
('state', '=', 'active'),
|
||||||
|
])
|
||||||
|
existing.unlink()
|
||||||
|
asn = Assignment.create({
|
||||||
|
'plan_id': plan.id,
|
||||||
|
'mode': 'students',
|
||||||
|
'student_user_ids': [(6, 0, [sarah.id])],
|
||||||
|
'message': 'Smoke test assignment',
|
||||||
|
'state': 'active',
|
||||||
|
})
|
||||||
|
ok(f"created assignment #{asn.id} for {len(asn.student_user_ids)} student(s)")
|
||||||
|
print(f" to_api_dict: {json.dumps(asn.to_api_dict(), default=str)[:200]} ...")
|
||||||
|
env.cr.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Phase E
|
||||||
|
hr("Phase E — Student visibility")
|
||||||
|
expanded = asn.expand_user_ids()
|
||||||
|
print(f" expand_user_ids -> {expanded}")
|
||||||
|
if sarah.id in expanded:
|
||||||
|
ok("sarah@encoach.test is included in assignment expansion")
|
||||||
|
else:
|
||||||
|
fail("sarah not visible in assignment.expand_user_ids()")
|
||||||
|
|
||||||
|
assignments = Assignment.search([
|
||||||
|
('state', '=', 'active'),
|
||||||
|
('student_user_ids', 'in', [sarah.id]),
|
||||||
|
])
|
||||||
|
visible_plans = {a.plan_id.id for a in assignments
|
||||||
|
if sarah.id in a.expand_user_ids()}
|
||||||
|
print(f" Sarah will see plans: {sorted(visible_plans)}")
|
||||||
|
if plan.id in visible_plans:
|
||||||
|
ok("Plan appears in student-side listing query")
|
||||||
|
else:
|
||||||
|
fail("Plan NOT in student-side listing")
|
||||||
|
|
||||||
|
|
||||||
|
hr("DONE — Course-plan smoke test passed (provider warnings are OK)")
|
||||||
|
print("Test source / assignment created. Review in admin UI on plan #%d." % plan.id)
|
||||||
Reference in New Issue
Block a user