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:
@@ -1,3 +1,5 @@
|
||||
from . import ai_controller
|
||||
from . import coach_controller
|
||||
from . import media_controller
|
||||
from . import prompt_controller
|
||||
from . import feedback_controller
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
217
custom_addons/encoach_ai/controllers/feedback_controller.py
Normal file
217
custom_addons/encoach_ai/controllers/feedback_controller.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
219
custom_addons/encoach_ai/controllers/prompt_controller.py
Normal file
219
custom_addons/encoach_ai/controllers/prompt_controller.py
Normal 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)
|
||||
Reference in New Issue
Block a user