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_settings
|
||||
from . import ai_log
|
||||
from . import ai_prompt
|
||||
from . import ai_feedback
|
||||
from . import constants
|
||||
|
||||
134
custom_addons/encoach_ai/models/ai_feedback.py
Normal file
134
custom_addons/encoach_ai/models/ai_feedback.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Student feedback on AI-generated content (thumbs up/down).
|
||||
|
||||
This closes the AI quality loop started by the human-review workflow (P3.3)
|
||||
and the versioned prompts (P3.4):
|
||||
|
||||
* Every AI-generated artefact (a question, a coach reply, an explanation, a
|
||||
translation, ...) can have any number of feedback rows attached to it.
|
||||
* Feedback is always coupled to a **subject type + subject id** so it can
|
||||
point at anything (``encoach.question``, ``encoach.ai.log``, ...) without
|
||||
forcing a hard FK per model.
|
||||
* When the student flags feedback as negative, we also store their free-text
|
||||
note so product can triage.
|
||||
|
||||
Downstream consumers (prompt library, RAG indexers, report dashboards) can
|
||||
read ``encoach.ai.feedback`` to compute win rates per prompt key, per model,
|
||||
per entity, etc.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class EncoachAIFeedback(models.Model):
|
||||
_name = "encoach.ai.feedback"
|
||||
_description = "Student feedback on AI-generated content"
|
||||
_order = "create_date desc"
|
||||
_rec_name = "subject_key"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# What was rated
|
||||
# ------------------------------------------------------------------
|
||||
subject_type = fields.Selection(
|
||||
[
|
||||
("question", "Exam question"),
|
||||
("coach", "AI Coach reply"),
|
||||
("explanation", "Content explanation"),
|
||||
("translation", "Translation"),
|
||||
("narrative", "Report narrative"),
|
||||
("other", "Other AI output"),
|
||||
],
|
||||
required=True, index=True,
|
||||
)
|
||||
subject_id = fields.Integer(
|
||||
required=True, index=True,
|
||||
help="Id of the rated artefact (semantics depend on subject_type).",
|
||||
)
|
||||
subject_key = fields.Char(
|
||||
compute="_compute_subject_key", store=True, index=True,
|
||||
help="Denormalised '<type>:<id>' key for quick lookups/aggregations.",
|
||||
)
|
||||
|
||||
# Optional link to the AI call that produced this artefact. When set, it
|
||||
# lets us correlate feedback with the underlying model/prompt used.
|
||||
ai_log_id = fields.Many2one(
|
||||
"encoach.ai.log", ondelete="set null", index=True,
|
||||
)
|
||||
prompt_key = fields.Char(
|
||||
index=True,
|
||||
help="If the artefact was produced via encoach.ai.prompt, record the "
|
||||
"key here so we can compute win-rates per prompt.",
|
||||
)
|
||||
prompt_version = fields.Integer()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rating
|
||||
# ------------------------------------------------------------------
|
||||
rating = fields.Selection(
|
||||
[("up", "Thumbs up"), ("down", "Thumbs down")],
|
||||
required=True, index=True,
|
||||
)
|
||||
comment = fields.Text(
|
||||
help="Free-text note. Required when rating is 'down' and the UI "
|
||||
"prompts the student for a reason.",
|
||||
)
|
||||
tags = fields.Char(
|
||||
help="Comma-separated tags (e.g. 'wrong-answer,confusing-stem').",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Who / context
|
||||
# ------------------------------------------------------------------
|
||||
user_id = fields.Many2one(
|
||||
"res.users", required=True, index=True,
|
||||
default=lambda self: self.env.user,
|
||||
)
|
||||
entity_id = fields.Many2one(
|
||||
"encoach.entity", ondelete="set null", index=True,
|
||||
)
|
||||
course_id = fields.Many2one(
|
||||
"encoach.course", ondelete="set null", index=True,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Triage status (filled in by admins/coaches reviewing feedback).
|
||||
# ------------------------------------------------------------------
|
||||
status = fields.Selection(
|
||||
[
|
||||
("open", "Open"),
|
||||
("acknowledged", "Acknowledged"),
|
||||
("fixed", "Fixed"),
|
||||
("dismissed", "Dismissed"),
|
||||
],
|
||||
default="open", index=True,
|
||||
)
|
||||
resolved_by_id = fields.Many2one(
|
||||
"res.users", ondelete="set null",
|
||||
)
|
||||
resolved_at = fields.Datetime()
|
||||
resolution_notes = fields.Text()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Constraints
|
||||
# ------------------------------------------------------------------
|
||||
_sql_constraints = [
|
||||
(
|
||||
"uniq_user_subject",
|
||||
"unique(user_id, subject_type, subject_id)",
|
||||
"A user can only leave one feedback row per AI artefact. "
|
||||
"Subsequent submissions should update the existing row.",
|
||||
),
|
||||
]
|
||||
|
||||
@api.depends("subject_type", "subject_id")
|
||||
def _compute_subject_key(self):
|
||||
for rec in self:
|
||||
rec.subject_key = f"{rec.subject_type or ''}:{rec.subject_id or 0}"
|
||||
|
||||
@api.constrains("subject_type", "subject_id")
|
||||
def _check_subject(self):
|
||||
for rec in self:
|
||||
if not rec.subject_type or not rec.subject_id:
|
||||
raise ValidationError("subject_type and subject_id are required.")
|
||||
213
custom_addons/encoach_ai/models/ai_prompt.py
Normal file
213
custom_addons/encoach_ai/models/ai_prompt.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Versioned AI prompt templates.
|
||||
|
||||
Context (ADR-0004, P3.4 hardening release): we want non-engineers to iterate
|
||||
on LLM prompts without shipping code. Every time an author edits a prompt we
|
||||
keep the previous revision around so we can A/B test, audit, and roll back.
|
||||
|
||||
Design:
|
||||
|
||||
* A **prompt** is identified by its ``key`` (e.g. ``exam.mcq.generate``) — a
|
||||
stable, dotted string that callers reference from Python.
|
||||
* Each save produces a new **version** row (bumped ``version`` int). The old
|
||||
rows stay with ``is_active = False`` so history is preserved; only one row
|
||||
per ``key`` can be active at a time (enforced in ``write``/``create``).
|
||||
* Callers look the prompt up with ``get_active(key)`` and render it with
|
||||
``render(variables)``. Rendering uses :py:meth:`str.format_map` so templates
|
||||
are just ``{variable}`` placeholders — no new DSL to learn.
|
||||
|
||||
This module intentionally does **not** call the LLM itself. It is a
|
||||
content-management layer that the existing ``encoach_ai.services.*`` pipelines
|
||||
can adopt at their own pace (falling back to their hard-coded strings until a
|
||||
prompt with the matching key is created in the UI).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Keys look like ``namespace.sub_namespace.name`` — lowercase, dotted.
|
||||
_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||
# Variables pulled from ``{foo}`` / ``{foo.bar}`` placeholders.
|
||||
_VAR_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_.]*)\}")
|
||||
|
||||
|
||||
class EncoachAIPrompt(models.Model):
|
||||
_name = "encoach.ai.prompt"
|
||||
_description = "Versioned AI prompt template"
|
||||
_order = "key, version desc"
|
||||
_rec_name = "display_name"
|
||||
|
||||
key = fields.Char(
|
||||
required=True,
|
||||
index=True,
|
||||
help="Stable dotted identifier (e.g. 'exam.mcq.generate'). "
|
||||
"Callers reference this from Python.",
|
||||
)
|
||||
version = fields.Integer(required=True, default=1, index=True)
|
||||
title = fields.Char(
|
||||
required=True,
|
||||
help="Short human label shown in the admin editor.",
|
||||
)
|
||||
description = fields.Text(
|
||||
help="Author intent, expected variables, known limitations.",
|
||||
)
|
||||
content = fields.Text(
|
||||
required=True,
|
||||
help="Template body. Use {variable} placeholders; "
|
||||
"render() fills them via str.format_map.",
|
||||
)
|
||||
is_active = fields.Boolean(
|
||||
default=False, index=True,
|
||||
help="Exactly one active row per key. Creating/activating a new row "
|
||||
"automatically deactivates the previous active version.",
|
||||
)
|
||||
author_id = fields.Many2one("res.users", ondelete="set null", readonly=True)
|
||||
activated_at = fields.Datetime(readonly=True)
|
||||
|
||||
# Denormalised for quick display/search. Recomputed on save.
|
||||
variables = fields.Char(
|
||||
compute="_compute_variables", store=True,
|
||||
help="Comma-separated list of {variable} placeholders found in content.",
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute="_compute_display_name", store=True,
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"key_version_uniq",
|
||||
"unique(key, version)",
|
||||
"A prompt version must be unique for its key.",
|
||||
),
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Computed fields
|
||||
# ------------------------------------------------------------------
|
||||
@api.depends("content")
|
||||
def _compute_variables(self):
|
||||
for rec in self:
|
||||
if not rec.content:
|
||||
rec.variables = ""
|
||||
continue
|
||||
found = sorted(set(_VAR_RE.findall(rec.content)))
|
||||
rec.variables = ",".join(found)
|
||||
|
||||
@api.depends("key", "version", "title")
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
rec.display_name = f"{rec.key} v{rec.version} — {rec.title or ''}".rstrip(" —")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Validation
|
||||
# ------------------------------------------------------------------
|
||||
@api.constrains("key")
|
||||
def _check_key_shape(self):
|
||||
for rec in self:
|
||||
if not rec.key or not _KEY_RE.match(rec.key):
|
||||
raise ValidationError(
|
||||
f"Invalid prompt key {rec.key!r}. "
|
||||
"Use lowercase dotted identifiers like 'exam.mcq.generate'."
|
||||
)
|
||||
|
||||
@api.constrains("content")
|
||||
def _check_content_non_empty(self):
|
||||
for rec in self:
|
||||
if not (rec.content or "").strip():
|
||||
raise ValidationError("Prompt content cannot be empty.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Create / write — enforce "one active per key"
|
||||
# ------------------------------------------------------------------
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
# Default version = max(existing) + 1 when caller omits it.
|
||||
for vals in vals_list:
|
||||
if "version" not in vals or not vals.get("version"):
|
||||
key = vals.get("key")
|
||||
existing = self.search(
|
||||
[("key", "=", key)], order="version desc", limit=1,
|
||||
)
|
||||
vals["version"] = (existing.version + 1) if existing else 1
|
||||
vals.setdefault("author_id", self.env.user.id)
|
||||
if vals.get("is_active"):
|
||||
vals.setdefault("activated_at", fields.Datetime.now())
|
||||
records = super().create(vals_list)
|
||||
# Post-create: enforce exclusivity for any rows created as active.
|
||||
for rec in records:
|
||||
if rec.is_active:
|
||||
rec._deactivate_siblings()
|
||||
return records
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if vals.get("is_active"):
|
||||
now = fields.Datetime.now()
|
||||
for rec in self:
|
||||
if rec.is_active and not rec.activated_at:
|
||||
super(EncoachAIPrompt, rec).write({"activated_at": now})
|
||||
rec._deactivate_siblings()
|
||||
return res
|
||||
|
||||
def _deactivate_siblings(self):
|
||||
for rec in self:
|
||||
if not rec.is_active:
|
||||
continue
|
||||
siblings = self.search([
|
||||
("key", "=", rec.key),
|
||||
("id", "!=", rec.id),
|
||||
("is_active", "=", True),
|
||||
])
|
||||
if siblings:
|
||||
siblings.write({"is_active": False})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API (called by pipelines / controllers)
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def get_active(self, key: str):
|
||||
"""Return the active prompt record for ``key`` (or an empty set)."""
|
||||
return self.sudo().search(
|
||||
[("key", "=", key), ("is_active", "=", True)], limit=1,
|
||||
)
|
||||
|
||||
def render(self, variables: dict | None = None) -> str:
|
||||
"""Fill ``{placeholders}`` using ``variables`` (dict)."""
|
||||
self.ensure_one()
|
||||
variables = variables or {}
|
||||
missing = self._missing_variables(variables.keys())
|
||||
if missing:
|
||||
raise UserError(
|
||||
f"Missing prompt variables for {self.key} v{self.version}: "
|
||||
f"{', '.join(sorted(missing))}"
|
||||
)
|
||||
try:
|
||||
return self.content.format_map(_SafeFormatDict(variables))
|
||||
except Exception as exc:
|
||||
_logger.exception("prompt render failed for %s v%s", self.key, self.version)
|
||||
raise UserError(f"Prompt render failed: {exc}") from exc
|
||||
|
||||
def _missing_variables(self, provided: Iterable[str]) -> set[str]:
|
||||
self.ensure_one()
|
||||
required = set(_VAR_RE.findall(self.content or ""))
|
||||
provided_root = {p.split(".")[0] for p in provided}
|
||||
return {v for v in required if v.split(".")[0] not in provided_root}
|
||||
|
||||
|
||||
class _SafeFormatDict(dict):
|
||||
"""Dict that returns ``''`` for dotted/nested lookups str.format can't resolve.
|
||||
|
||||
We don't advertise ``{foo.bar}`` as supported (the editor shows top-level
|
||||
variables only), but we also don't want rendering to explode if a caller
|
||||
passes an object with attributes.
|
||||
"""
|
||||
|
||||
def __missing__(self, key):
|
||||
return "{" + key + "}"
|
||||
Reference in New Issue
Block a user