feat(backend): Phase 2/3 hardening release

Roadmap P0 — platform safety & ops
- Merge duplicate encoach.student.attempt/answer models into encoach_scoring
  and drop the stale encoach_exam_template copies.
- Remove duplicate /api/exam/* routes; canonicalize on one controller tree.
- Gate raw-SQL seeds in seed_demo_data.py behind an explicit env flag.
- Add /api/health and /api/health/ready (DB + LLM reachability) endpoints.
- Fix docker-compose + ship odoo-docker.conf for container-local runs.
- Enforce OpenAI request_timeout=30s and @jwt_required on all AI/coach routes.
- Promote canonical cefr_mapper to encoach_ai.services.cefr_mapper.
- JWT cache TTL=30s + invalidation hook on user mutation.

Roadmap P1 — exam correctness & data provenance
- Wire QualityChecker + IeltsValidator into exam submit with a
  pending_review gate (encoach_ai.services.question_validator).
- Populate RAG metadata (course_id, subject_id, entity_id, taxonomy) on
  encoach_vector embeddings and add a chunking pipeline (>2000 chars).
- Add provenance fields on encoach.question (model, prompt_hash, log_id)
  and validate LLM output with schema before DB insert.
- Unify response envelope to {items,total,page,size}.
- Approval reject rollback with savepoint atomicity.
- Ticket notifications on status/assignee change.

Roadmap P2 — performance & observability
- Reports: replace Python loops with SQL read_group aggregations.
- X-Request-ID middleware + structured JSON logs.
- In-process/Prometheus counters and openapi.py controller exporting a
  spec by scanning @http.route decorators.
- Paymob real checkout + HMAC-SHA512 webhook verification, backed by a
  new encoach.paymob.order model and ir.config_parameter credentials.
- JWT refresh tokens + revocation table.
- Composite DB indexes on hot report/ticket/attempt paths.

Roadmap P3 — human-in-the-loop & compliance
- Human-in-the-loop exam review workflow (pending_review → publish) with
  new review controller and status transitions.
- encoach.ai.prompt model + versioning + admin editor endpoints (one
  active version per key, render-preview dry run).
- Student feedback loop → encoach.ai.feedback (upsert per user/subject,
  admin triage + resolve endpoints).
- GDPR export (/api/gdpr/export) and right-to-erasure (/api/gdpr/delete)
  with anonymization, tombstone record, and admin-self-erasure guard.
- HttpCase smoke tests for /api/health and /api/health/ready.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-19 14:16:09 +04:00
parent 1a0349c381
commit 3972023a30
64 changed files with 4121 additions and 701 deletions

View File

@@ -1,3 +1,5 @@
from . import ai_controller
from . import coach_controller
from . import media_controller
from . import prompt_controller
from . import feedback_controller

View File

@@ -2,8 +2,9 @@
import json
import logging
from odoo import http
from odoo import fields, http
from odoo.http import request, Response
from odoo.addons.encoach_api.controllers.base import jwt_required
_logger = logging.getLogger(__name__)
@@ -27,7 +28,8 @@ class AIController(http.Controller):
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
# ── POST /api/ai/search — AiSearchBar.tsx (RAG-enhanced) ──
@http.route("/api/ai/search", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/ai/search", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_search(self, **kw):
body = _get_json()
query = body.get("query", "")
@@ -43,7 +45,8 @@ class AIController(http.Controller):
return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []})
# ── GET /api/ai/vector-search — pure semantic search without GPT ──
@http.route("/api/ai/vector-search", type="http", auth="public", methods=["GET"], csrf=False)
@http.route("/api/ai/vector-search", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def ai_vector_search(self, **kw):
query = request.params.get("q", "")
content_type = request.params.get("content_type")
@@ -60,7 +63,8 @@ class AIController(http.Controller):
return _json_response({"results": [], "query": query, "error": str(e)})
# ── POST /api/ai/insights — AiInsightsPanel.tsx ──
@http.route("/api/ai/insights", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/ai/insights", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_insights(self, **kw):
body = _get_json()
try:
@@ -76,7 +80,8 @@ class AIController(http.Controller):
return _json_response({"insights": [{"title": "AI Unavailable", "description": str(e), "severity": "info", "recommendation": "Check AI settings."}]})
# ── GET /api/ai/alerts — AiAlertBanner.tsx ──
@http.route("/api/ai/alerts", type="http", auth="public", methods=["GET"], csrf=False)
@http.route("/api/ai/alerts", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def ai_alerts(self, **kw):
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
@@ -92,7 +97,8 @@ class AIController(http.Controller):
return _json_response({"alerts": []})
# ── POST /api/ai/report-narrative — AiReportNarrative.tsx ──
@http.route("/api/ai/report-narrative", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/ai/report-narrative", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_report_narrative(self, **kw):
body = _get_json()
try:
@@ -107,7 +113,8 @@ class AIController(http.Controller):
return _json_response({"narrative": f"Report generation unavailable: {e}"})
# ── POST /api/ai/batch-optimize — AiBatchOptimizer.tsx ──
@http.route("/api/ai/batch-optimize", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/ai/batch-optimize", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_batch_optimize(self, **kw):
body = _get_json()
try:
@@ -122,7 +129,8 @@ class AIController(http.Controller):
return _json_response({"optimized": [], "summary": str(e), "impact": "none"})
# ── POST /api/ai/grade-suggest — AiGradingAssistant.tsx ──
@http.route("/api/ai/grade-suggest", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/ai/grade-suggest", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_grade_suggest(self, **kw):
body = _get_json()
try:
@@ -146,7 +154,8 @@ class AIController(http.Controller):
return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []})
# ── POST /api/ai/generate-resource — ModuleBuilder.tsx (dedup-aware) ──
@http.route("/api/ai/generate-resource", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/ai/generate-resource", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_generate_resource(self, **kw):
body = _get_json()
try:
@@ -162,7 +171,8 @@ class AIController(http.Controller):
return _json_response({"resource": None, "status": "error", "error": str(e)})
# ── POST /api/ai/detect — GPTZero AI detection ──
@http.route("/api/ai/detect", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/ai/detect", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_detect(self, **kw):
body = _get_json()
try:
@@ -174,7 +184,8 @@ class AIController(http.Controller):
return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)})
# ── POST /api/plagiarism/check — plagiarism.service.ts ──
@http.route("/api/plagiarism/check", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/plagiarism/check", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def plagiarism_check(self, **kw):
body = _get_json()
try:
@@ -187,7 +198,8 @@ class AIController(http.Controller):
return _json_response({"report_id": None, "error": str(e)})
# ── POST /api/domains/:domainId/ai-suggest — TaxonomyManager.tsx ──
@http.route("/api/domains/<int:domain_id>/ai-suggest", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/domains/<int:domain_id>/ai-suggest", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_suggest_topics(self, domain_id, **kw):
body = _get_json()
try:
@@ -206,7 +218,8 @@ class AIController(http.Controller):
return _json_response({"topics": [], "error": str(e)})
# ── POST /api/learning-plan/generate — LearningPlan.tsx ──
@http.route("/api/learning-plan/generate", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/learning-plan/generate", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def learning_plan_generate(self, **kw):
body = _get_json()
try:
@@ -227,7 +240,8 @@ class AIController(http.Controller):
return _json_response({"plan": None, "error": str(e)})
# ── Workbench endpoints — AiWorkbench.tsx ──
@http.route("/api/workbench/generate-outline", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/workbench/generate-outline", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def workbench_outline(self, **kw):
body = _get_json()
try:
@@ -244,7 +258,8 @@ class AIController(http.Controller):
except Exception as e:
return _json_response({"chapters": [], "error": str(e)})
@http.route("/api/workbench/generate-chapter", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/workbench/generate-chapter", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def workbench_chapter(self, **kw):
body = _get_json()
try:
@@ -262,7 +277,8 @@ class AIController(http.Controller):
except Exception as e:
return _json_response({"content": "", "error": str(e)})
@http.route("/api/workbench/generate-rubric", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/workbench/generate-rubric", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def workbench_rubric(self, **kw):
body = _get_json()
try:
@@ -280,11 +296,13 @@ class AIController(http.Controller):
except Exception as e:
return _json_response({"rubric": None, "error": str(e)})
@http.route("/api/workbench/regenerate", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/workbench/regenerate", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def workbench_regenerate(self, **kw):
return self.workbench_chapter(**kw)
@http.route("/api/workbench/publish", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/workbench/publish", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def workbench_publish(self, **kw):
body = _get_json()
try:
@@ -315,7 +333,8 @@ class AIController(http.Controller):
return _json_response({"status": "error", "error": str(e)}, 500)
# ── POST /api/ai/suggest-rubric-criteria — RubricsPage.tsx ──
@http.route("/api/ai/suggest-rubric-criteria", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/ai/suggest-rubric-criteria", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_suggest_rubric_criteria(self, **kw):
from odoo.addons.encoach_api.controllers.base import validate_token
user = validate_token()
@@ -450,7 +469,8 @@ class AIController(http.Controller):
]
# ── Exam generation — GenerationPage.tsx ──
@http.route("/api/exam/<string:module>/generate", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/exam/<string:module>/generate", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def exam_generate(self, module, **kw):
from odoo.addons.encoach_api.controllers.base import validate_token
user = validate_token()
@@ -1252,7 +1272,8 @@ class AIController(http.Controller):
return {"questions": questions}
# ── POST /api/exam/generation/submit — create exam from generation page ──
@http.route("/api/exam/generation/submit", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/exam/generation/submit", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def generation_submit(self, **kw):
from odoo.addons.encoach_api.controllers.base import validate_token
user = validate_token()
@@ -1265,12 +1286,40 @@ class AIController(http.Controller):
if not title:
return _json_response({"error": "title is required"}, 400)
from odoo.addons.encoach_ai.services.question_validator import (
validate_payload, score_question, summarize_question_reports,
dumps_report, prompt_hash as _prompt_hash,
VERDICT_FAIL, VERDICT_WARN,
)
schema_report = validate_payload(body)
if schema_report["verdict"] == VERDICT_FAIL:
_logger.warning(
"generation_submit rejected %d schema errors: %s",
len(schema_report["errors"]), schema_report["errors"][:3],
)
return _json_response({
"error": "AI payload failed schema validation",
"details": schema_report,
}, 422)
label = body.get("label", "")
modules = body.get("modules", {})
skip_approval = body.get("skip_approval", False)
exam_mode = body.get("exam_mode", "official")
structure_id = body.get("structure_id")
ai_model_used = (
body.get("ai_model")
or request.env["ir.config_parameter"].sudo().get_param(
"encoach_ai.openai_fast_model", "gpt-4o-mini"
)
)
payload_hash = _prompt_hash(json.dumps(
{"title": title, "modules": modules}, sort_keys=True, default=str,
))
question_reports = [] # collected for aggregate gating below
first_mod = next(iter(modules.values()), {}) if modules else {}
entity_val = first_mod.get("entity", "none")
entity_id = int(entity_val) if entity_val and entity_val != "none" else False
@@ -1304,13 +1353,14 @@ class AIController(http.Controller):
except KeyError:
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
initial_status = "published" if skip_approval else "draft"
exam_vals = {
"title": title,
"label": label,
"exam_mode": exam_mode,
"teacher_id": request.env.user.id,
"template_id": template_id,
"status": "published" if skip_approval else "draft",
"status": initial_status,
"total_time_min": sum(m.get("timer", 0) for m in modules.values()),
"total_marks": sum(float(m.get("totalMarks", 0)) for m in modules.values()),
"randomize_questions": any(m.get("shuffling", False) for m in modules.values()),
@@ -1344,6 +1394,27 @@ class AIController(http.Controller):
"form_completion": "form_completion", "map_labelling": "map_labelling",
}
now = fields.Datetime.now() if hasattr(fields, 'Datetime') else None
def _create_question(vals, *, cefr_level=None):
stem = vals.get("stem") or ""
q_report = score_question(
stem, skill=vals.get("skill"), cefr_level=cefr_level
)
question_reports.append(q_report)
status = vals.pop("status", "draft")
if q_report["verdict"] == VERDICT_FAIL:
status = "flagged"
vals.update({
"status": status,
"ai_model_used": ai_model_used,
"ai_prompt_hash": payload_hash,
"ai_generated_at": now,
"quality_score": q_report.get("score"),
"quality_report": dumps_report(q_report),
})
return Question.create(vals)
seq = 10
total_questions = 0
for mod_key, mod_data in modules.items():
@@ -1381,7 +1452,7 @@ class AIController(http.Controller):
for ex in (passage.get("exercises") or []):
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
opts = ex.get("options", [])
q = Question.create({
q = _create_question({
"skill": mod_key if mod_key in ("reading", "listening", "writing", "speaking", "grammar", "vocabulary", "math", "it") else "reading",
"source_type": "passage",
"question_type": q_type,
@@ -1392,7 +1463,7 @@ class AIController(http.Controller):
"difficulty": _q_difficulty_for(ex),
"status": "active",
"ai_generated": True,
})
}, cefr_level=(ex.get("cefr_level") or cefr_level))
question_ids.append(q.id)
sections_data = mod_data.get("sections") or []
@@ -1402,7 +1473,7 @@ class AIController(http.Controller):
for ex in (s_data.get("exercises") or []):
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
opts = ex.get("options", [])
q = Question.create({
q = _create_question({
"skill": "listening",
"source_type": "audio",
"question_type": q_type,
@@ -1413,12 +1484,12 @@ class AIController(http.Controller):
"difficulty": _q_difficulty_for(ex),
"status": "active",
"ai_generated": True,
})
}, cefr_level=(ex.get("cefr_level") or cefr_level))
question_ids.append(q.id)
tasks = mod_data.get("tasks") or []
for t_idx, task in enumerate(tasks):
q = Question.create({
q = _create_question({
"skill": "writing",
"source_type": "writing_prompt",
"question_type": "short_answer",
@@ -1429,12 +1500,12 @@ class AIController(http.Controller):
"difficulty": q_difficulty,
"status": "active",
"ai_generated": True,
})
}, cefr_level=cefr_level)
question_ids.append(q.id)
parts = mod_data.get("parts") or []
for p_idx, part in enumerate(parts):
q = Question.create({
q = _create_question({
"skill": "speaking",
"source_type": "speaking_card",
"question_type": "short_answer",
@@ -1445,7 +1516,7 @@ class AIController(http.Controller):
"difficulty": q_difficulty,
"status": "active",
"ai_generated": True,
})
}, cefr_level=cefr_level)
question_ids.append(q.id)
if question_ids:
@@ -1455,18 +1526,37 @@ class AIController(http.Controller):
})
total_questions += len(question_ids)
quality_summary = summarize_question_reports(question_reports)
if (
exam.status != "draft"
and quality_summary["verdict"] in (VERDICT_FAIL, VERDICT_WARN)
and quality_summary["total"] > 0
):
exam.sudo().write({"status": "pending_review"})
_logger.info(
"exam %s forced pending_review: avg_score=%.2f failed=%d warned=%d",
exam.id, quality_summary["avg_score"],
quality_summary["failed"], quality_summary["warned"],
)
return _json_response({
"exam_id": exam.id,
"status": exam.status,
"template_id": template_id,
"total_questions": total_questions,
"quality": quality_summary,
"schema_validation": {
"verdict": schema_report["verdict"],
"warnings": schema_report["warnings"],
},
}, 201)
except Exception as e:
_logger.exception("generation submit failed")
return _json_response({"error": str(e)}, 500)
# ── POST /api/ai/batch-optimize/apply — persist batch optimization ──
@http.route("/api/ai/batch-optimize/apply", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/ai/batch-optimize/apply", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_batch_optimize_apply(self, **kw):
body = _get_json()
optimized = body.get("optimized", [])
@@ -1481,7 +1571,8 @@ class AIController(http.Controller):
return _json_response({"applied": 0, "error": str(e)}, 500)
# ── POST /api/exam/<module>/generate/save — save generated exam items ──
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def exam_generate_save(self, module, **kw):
from odoo.addons.encoach_api.controllers.base import validate_token
user = validate_token()
@@ -1521,7 +1612,8 @@ class AIController(http.Controller):
return _json_response({"saved": 0, "error": str(e)}, 500)
# ── POST /api/workbench/suggest-materials — AI material suggestions ──
@http.route("/api/workbench/suggest-materials", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/workbench/suggest-materials", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def workbench_suggest_materials(self, **kw):
body = _get_json()
try:
@@ -1541,7 +1633,8 @@ class AIController(http.Controller):
return _json_response({"materials": [], "error": str(e)})
# ── Topic content generation — adaptive ──
@http.route("/api/topics/<int:topic_id>/generate-content", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/topics/<int:topic_id>/generate-content", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def topic_generate_content(self, topic_id, **kw):
body = _get_json()
try:

View File

@@ -4,6 +4,10 @@ import json
import logging
from odoo import http
from odoo.http import request, Response
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response as _base_json_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
@@ -27,7 +31,8 @@ class CoachController(http.Controller):
return CoachService(request.env)
# ── POST /api/coach/chat — AiAssistantDrawer.tsx ──
@http.route("/api/coach/chat", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def coach_chat(self, **kw):
body = _get_json()
try:
@@ -43,7 +48,8 @@ class CoachController(http.Controller):
return _json_response({"reply": f"I'm having trouble right now. Error: {e}"})
# ── GET /api/coach/tip — AiTipBanner.tsx ──
@http.route("/api/coach/tip", type="http", auth="public", methods=["GET"], csrf=False)
@http.route("/api/coach/tip", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def coach_tip(self, **kw):
context = request.params.get("context", "general")
try:
@@ -53,7 +59,8 @@ class CoachController(http.Controller):
return _json_response({"tip": "Keep practising every day — consistency beats intensity!", "category": "general"})
# ── POST /api/coach/explain — AiGradeExplainer.tsx ──
@http.route("/api/coach/explain", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/coach/explain", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def coach_explain(self, **kw):
body = _get_json()
try:
@@ -67,7 +74,8 @@ class CoachController(http.Controller):
return _json_response({"explanation": f"Could not generate explanation: {e}"})
# ── POST /api/coach/suggest — AiStudyCoach.tsx ──
@http.route("/api/coach/suggest", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/coach/suggest", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def coach_suggest(self, **kw):
body = _get_json()
try:
@@ -82,7 +90,8 @@ class CoachController(http.Controller):
})
# ── POST /api/coach/writing-help — AiWritingHelper.tsx ──
@http.route("/api/coach/writing-help", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/coach/writing-help", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def coach_writing_help(self, **kw):
body = _get_json()
try:
@@ -97,7 +106,8 @@ class CoachController(http.Controller):
return _json_response({"improved_text": "", "changes": [], "tips": [str(e)]})
# ── POST /api/coach/hint — (unused component, wired for completeness) ──
@http.route("/api/coach/hint", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/coach/hint", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def coach_hint(self, **kw):
body = _get_json()
try:

View File

@@ -0,0 +1,217 @@
"""Endpoints for submitting & aggregating AI feedback."""
import logging
from odoo import fields, http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
_error_response,
_get_json_body,
_json_response,
_paginate,
jwt_required,
)
_logger = logging.getLogger(__name__)
def _feedback_to_dict(row):
return {
"id": row.id,
"subject_type": row.subject_type,
"subject_id": row.subject_id,
"subject_key": row.subject_key,
"rating": row.rating,
"comment": row.comment or "",
"tags": (row.tags or "").split(",") if row.tags else [],
"prompt_key": row.prompt_key or None,
"prompt_version": row.prompt_version or None,
"ai_log_id": row.ai_log_id.id if row.ai_log_id else None,
"user_id": row.user_id.id,
"user_name": row.user_id.name,
"entity_id": row.entity_id.id if row.entity_id else None,
"course_id": row.course_id.id if row.course_id else None,
"status": row.status,
"create_date": (
fields.Datetime.to_string(row.create_date) if row.create_date else None
),
"resolved_at": (
fields.Datetime.to_string(row.resolved_at) if row.resolved_at else None
),
"resolution_notes": row.resolution_notes or "",
}
class EncoachAIFeedbackController(http.Controller):
"""Feedback write + admin triage endpoints."""
# ------------------------------------------------------------------
# POST /api/ai/feedback — student submits/updates feedback
#
# Upsert semantics: we enforce uniqueness per (user, subject_type,
# subject_id) at the DB layer, so the client doesn't need to know whether
# the row already exists.
# ------------------------------------------------------------------
@http.route("/api/ai/feedback", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def submit(self, **kw):
try:
body = _get_json_body() or {}
subject_type = body.get("subject_type")
subject_id = body.get("subject_id")
rating = body.get("rating")
if subject_type not in {
"question", "coach", "explanation", "translation", "narrative", "other",
}:
return _error_response("Invalid subject_type", 400)
if not isinstance(subject_id, int) or subject_id <= 0:
return _error_response("subject_id must be a positive integer", 400)
if rating not in {"up", "down"}:
return _error_response("rating must be 'up' or 'down'", 400)
comment = body.get("comment") or ""
if rating == "down" and not (comment or "").strip():
# We keep this a soft constraint so the UI can decide how
# strict to be; returning an error here gives clients a clear
# path to prompt the user when needed.
return _error_response(
"A short comment is required when rating is 'down'.", 400,
)
vals = {
"subject_type": subject_type,
"subject_id": subject_id,
"rating": rating,
"comment": comment,
"tags": (
",".join(body.get("tags") or [])
if isinstance(body.get("tags"), list)
else (body.get("tags") or "")
),
"prompt_key": body.get("prompt_key") or False,
"prompt_version": body.get("prompt_version") or 0,
"ai_log_id": body.get("ai_log_id") or False,
"entity_id": body.get("entity_id") or False,
"course_id": body.get("course_id") or False,
"user_id": request.env.user.id,
}
Feedback = request.env["encoach.ai.feedback"].sudo()
existing = Feedback.search([
("user_id", "=", request.env.user.id),
("subject_type", "=", subject_type),
("subject_id", "=", subject_id),
], limit=1)
with request.env.cr.savepoint():
if existing:
existing.write(vals)
row = existing
else:
row = Feedback.create(vals)
return _json_response(_feedback_to_dict(row), 200 if existing else 201)
except Exception as e:
_logger.exception("ai feedback submit failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai/feedback/summary?subject_type=question&subject_id=42
# ------------------------------------------------------------------
@http.route(
"/api/ai/feedback/summary", type="http", auth="none", methods=["GET"], csrf=False,
)
@jwt_required
def summary(self, subject_type=None, subject_id=None, **kw):
try:
if not subject_type or not subject_id:
return _error_response("subject_type and subject_id are required", 400)
Feedback = request.env["encoach.ai.feedback"].sudo()
domain = [
("subject_type", "=", subject_type),
("subject_id", "=", int(subject_id)),
]
rows = Feedback.search(domain)
up = sum(1 for r in rows if r.rating == "up")
down = sum(1 for r in rows if r.rating == "down")
my_row = Feedback.search([
*domain,
("user_id", "=", request.env.user.id),
], limit=1)
return _json_response({
"subject_type": subject_type,
"subject_id": int(subject_id),
"up": up,
"down": down,
"total": up + down,
"my_rating": my_row.rating if my_row else None,
"my_comment": my_row.comment or "" if my_row else "",
})
except Exception as e:
_logger.exception("ai feedback summary failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai/feedback — admin triage list
# ------------------------------------------------------------------
@http.route("/api/ai/feedback", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def list_feedback(self, **kw):
try:
if not request.env.user.has_group("base.group_system"):
return _error_response("Admin privileges required", 403)
Feedback = request.env["encoach.ai.feedback"].sudo()
domain = []
if kw.get("status"):
domain.append(("status", "=", kw["status"]))
if kw.get("rating"):
domain.append(("rating", "=", kw["rating"]))
if kw.get("subject_type"):
domain.append(("subject_type", "=", kw["subject_type"]))
if kw.get("prompt_key"):
domain.append(("prompt_key", "=", kw["prompt_key"]))
offset, limit, page = _paginate(kw)
total = Feedback.search_count(domain)
rows = Feedback.search(domain, offset=offset, limit=limit, order="create_date desc")
items = [_feedback_to_dict(r) for r in rows]
return _json_response({
"items": items,
"data": items,
"total": total,
"page": page,
"size": limit,
})
except Exception as e:
_logger.exception("ai feedback list failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai/feedback/<id>/resolve — admin triage
# ------------------------------------------------------------------
@http.route(
'/api/ai/feedback/<int:feedback_id>/resolve',
type="http", auth="none", methods=["POST"], csrf=False,
)
@jwt_required
def resolve(self, feedback_id, **kw):
try:
if not request.env.user.has_group("base.group_system"):
return _error_response("Admin privileges required", 403)
body = _get_json_body() or {}
status = body.get("status")
if status not in {"acknowledged", "fixed", "dismissed"}:
return _error_response(
"status must be one of acknowledged|fixed|dismissed", 400,
)
row = request.env["encoach.ai.feedback"].sudo().browse(feedback_id)
if not row.exists():
return _error_response("Feedback not found", 404)
with request.env.cr.savepoint():
row.write({
"status": status,
"resolved_by_id": request.env.user.id,
"resolved_at": fields.Datetime.now(),
"resolution_notes": body.get("notes") or "",
})
return _json_response(_feedback_to_dict(row))
except Exception as e:
_logger.exception("ai feedback resolve failed")
return _error_response(str(e), 500)

View File

@@ -5,6 +5,10 @@ import json
import logging
from odoo import http
from odoo.http import request, Response
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response as _base_json_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
@@ -52,7 +56,8 @@ class MediaController(http.Controller):
)
# ── POST /api/exam/listening/media — generate listening audio ──
@http.route("/api/exam/listening/media", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/exam/listening/media", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def listening_media(self, **kw):
body = _get_json()
text = body.get("text", "")
@@ -72,7 +77,8 @@ class MediaController(http.Controller):
return _json_response({"error": str(e)}, 500)
# ── POST /api/exam/speaking/media — generate speaking prompt audio ──
@http.route("/api/exam/speaking/media", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/exam/speaking/media", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def speaking_media(self, **kw):
body = _get_json()
text = body.get("text", "")
@@ -89,7 +95,8 @@ class MediaController(http.Controller):
return _json_response({"error": str(e)}, 500)
# ── GET /api/exam/avatars — list ELAI avatars ──
@http.route("/api/exam/avatars", type="http", auth="public", methods=["GET"], csrf=False)
@http.route("/api/exam/avatars", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def list_avatars(self, **kw):
try:
from odoo.addons.encoach_ai.services.elai_service import ElaiService
@@ -100,7 +107,8 @@ class MediaController(http.Controller):
return _json_response({"avatars": [], "note": str(e)})
# ── POST /api/exam/avatar/video — create avatar video ──
@http.route("/api/exam/avatar/video", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/exam/avatar/video", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def create_avatar_video(self, **kw):
body = _get_json()
try:
@@ -116,7 +124,8 @@ class MediaController(http.Controller):
return _json_response({"error": str(e)}, 500)
# ── GET /api/exam/avatar/video/:id — check video status ──
@http.route("/api/exam/avatar/video/<string:video_id>", type="http", auth="public", methods=["GET"], csrf=False)
@http.route("/api/exam/avatar/video/<string:video_id>", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def video_status(self, video_id, **kw):
try:
from odoo.addons.encoach_ai.services.elai_service import ElaiService
@@ -126,7 +135,8 @@ class MediaController(http.Controller):
return _json_response({"video_id": video_id, "status": "error", "error": str(e)})
# ── POST /api/courses/ai-generate — AiCreationAssistant.tsx ──
@http.route("/api/courses/ai-generate", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/courses/ai-generate", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_generate_course(self, **kw):
body = _get_json()
try:

View File

@@ -0,0 +1,219 @@
"""Admin endpoints for the versioned AI prompt editor."""
import logging
from odoo import fields, http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
_error_response,
_get_json_body,
_json_response,
_paginate,
jwt_required,
)
_logger = logging.getLogger(__name__)
def _prompt_to_dict(prompt, *, include_content=True):
data = {
"id": prompt.id,
"key": prompt.key,
"version": prompt.version,
"title": prompt.title or "",
"description": prompt.description or "",
"is_active": bool(prompt.is_active),
"variables": (prompt.variables or "").split(",") if prompt.variables else [],
"author_id": prompt.author_id.id if prompt.author_id else None,
"author_name": prompt.author_id.name if prompt.author_id else "",
"activated_at": (
fields.Datetime.to_string(prompt.activated_at) if prompt.activated_at else None
),
"create_date": (
fields.Datetime.to_string(prompt.create_date) if prompt.create_date else None
),
}
if include_content:
data["content"] = prompt.content or ""
return data
class EncoachAIPromptController(http.Controller):
"""Versioned prompt CRUD.
Auth: every endpoint requires a valid JWT. The write endpoints additionally
check for admin rights via ``res.users.has_group('base.group_system')`` so
non-admins can read prompts but not mutate them.
"""
# ------------------------------------------------------------------
# GET /api/ai/prompts — list keys (latest version per key)
# ------------------------------------------------------------------
@http.route("/api/ai/prompts", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def list_keys(self, **kw):
try:
Prompt = request.env["encoach.ai.prompt"].sudo()
# One row per key = the latest version. We fetch candidates then
# deduplicate in Python because Odoo's ORM doesn't expose DISTINCT
# ON ergonomically.
search = (kw.get("search") or "").strip()
domain = [("key", "ilike", search)] if search else []
# Sort by key then descending version so the first occurrence per
# key is the latest.
all_prompts = Prompt.search(domain, order="key asc, version desc")
seen = set()
latest = []
for p in all_prompts:
if p.key in seen:
continue
seen.add(p.key)
latest.append(p)
offset, per_page, page = _paginate(kw)
total = len(latest)
window = latest[offset : offset + per_page]
items = [_prompt_to_dict(p, include_content=False) for p in window]
# Attach version-count for the UI to show "v5 (of 7)" hints.
counts = {}
for p in all_prompts:
counts[p.key] = counts.get(p.key, 0) + 1
for entry in items:
entry["total_versions"] = counts.get(entry["key"], 1)
return _json_response({
"items": items,
"data": items,
"total": total,
"page": page,
"size": per_page,
})
except Exception as e:
_logger.exception("list ai prompts failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai/prompts/<key>/versions
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<string:key>/versions',
type="http", auth="none", methods=["GET"], csrf=False,
)
@jwt_required
def list_versions(self, key, **kw):
try:
Prompt = request.env["encoach.ai.prompt"].sudo()
prompts = Prompt.search([("key", "=", key)], order="version desc")
if not prompts:
return _error_response("Prompt key not found", 404)
items = [_prompt_to_dict(p, include_content=False) for p in prompts]
return _json_response({
"key": key,
"items": items,
"data": items,
"total": len(items),
"page": 1,
"size": len(items),
})
except Exception as e:
_logger.exception("list prompt versions failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai/prompts/<int:prompt_id>
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<int:prompt_id>',
type="http", auth="none", methods=["GET"], csrf=False,
)
@jwt_required
def get_version(self, prompt_id, **kw):
try:
prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id)
if not prompt.exists():
return _error_response("Prompt not found", 404)
return _json_response(_prompt_to_dict(prompt))
except Exception as e:
_logger.exception("get prompt failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai/prompts — create a new version of a key (or a new key)
# ------------------------------------------------------------------
@http.route("/api/ai/prompts", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def create_version(self, **kw):
try:
if not request.env.user.has_group("base.group_system"):
return _error_response("Admin privileges required", 403)
body = _get_json_body() or {}
key = (body.get("key") or "").strip()
content = body.get("content") or ""
title = (body.get("title") or "").strip()
if not key or not content.strip() or not title:
return _error_response(
"key, title, and content are required", 400,
)
vals = {
"key": key,
"title": title,
"description": body.get("description") or "",
"content": content,
"is_active": bool(body.get("activate", False)),
}
with request.env.cr.savepoint():
prompt = request.env["encoach.ai.prompt"].sudo().create(vals)
return _json_response(_prompt_to_dict(prompt), 201)
except Exception as e:
_logger.exception("create prompt version failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai/prompts/<id>/activate
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<int:prompt_id>/activate',
type="http", auth="none", methods=["POST"], csrf=False,
)
@jwt_required
def activate(self, prompt_id, **kw):
try:
if not request.env.user.has_group("base.group_system"):
return _error_response("Admin privileges required", 403)
prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id)
if not prompt.exists():
return _error_response("Prompt not found", 404)
with request.env.cr.savepoint():
prompt.write({"is_active": True})
return _json_response(_prompt_to_dict(prompt))
except Exception as e:
_logger.exception("activate prompt failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai/prompts/<id>/render — dry-run render with sample variables
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<int:prompt_id>/render',
type="http", auth="none", methods=["POST"], csrf=False,
)
@jwt_required
def render_preview(self, prompt_id, **kw):
try:
prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id)
if not prompt.exists():
return _error_response("Prompt not found", 404)
body = _get_json_body() or {}
variables = body.get("variables") or {}
if not isinstance(variables, dict):
return _error_response("variables must be a JSON object", 400)
rendered = prompt.render(variables)
return _json_response({
"rendered": rendered,
"key": prompt.key,
"version": prompt.version,
})
except Exception as e:
_logger.exception("render prompt failed")
return _error_response(str(e), 400)