[verified] all roles train for real: remove preview, 1 persona = 1 chat per user
- listPersonas: compute per-user my_outcome for ALL roles; always set the key (default 'not_tried') so the all-roles UI never sees undefined (fixes global super_admin without org showing every persona as trained) - start_session/mode lookups (send/finish/resume/authorize): no preview mode; every role creates one-shot 'trainee' sessions. The one-shot lock (1 persona chat per user; many users per persona) now applies to all roles - Chat.vue: always start in trainee mode, remove preview label - Personas.vue: unified branches - show 'แชท' when untrained, 'สรุปผล'+variant when trained, for all roles - Rewrite test_admin_preview.py to assert the new no-preview one-shot behavior
This commit is contained in:
@@ -35,7 +35,7 @@ def _session_mutation(fn):
|
||||
def wrapped(gid: str, pid: str, *args, **kwargs):
|
||||
s = _stores()
|
||||
actor = current_user()
|
||||
mode = "preview" if actor.get("role") in ("admin", "super_admin") else "trainee"
|
||||
mode = "trainee" # no preview mode; every role trains one-shot
|
||||
session = s["sessions"].active_for_scope(
|
||||
org_id=actor.get("org_id"), user_id=actor["id"],
|
||||
group_id=gid, persona_id=pid, mode=mode,
|
||||
@@ -250,7 +250,7 @@ def _authorize_session_context(
|
||||
actor = current_user()
|
||||
actor_id = actor.get("id")
|
||||
org_id = actor.get("org_id")
|
||||
expected_mode = "preview" if actor.get("role") in ("admin", "super_admin") else "trainee"
|
||||
expected_mode = "trainee" # no preview mode; all sessions are one-shot trainee
|
||||
session_mode = session.get("mode")
|
||||
if session_mode is None:
|
||||
session_mode = "trainee" # legacy rows before explicit mode was added
|
||||
@@ -306,10 +306,11 @@ def start_session(gid: str, pid: str):
|
||||
requested_mode = body.get("mode", "trainee")
|
||||
if requested_mode not in ("trainee", "preview"):
|
||||
raise ApiError("session mode is invalid", 400)
|
||||
if requested_mode == "preview" and actor.get("role") not in ("admin", "super_admin"):
|
||||
raise ApiError("permission denied", 403)
|
||||
if actor.get("role") in ("admin", "super_admin") and requested_mode == "trainee":
|
||||
requested_mode = "preview"
|
||||
# No preview mode: every role trains for real. All sessions are one-shot
|
||||
# trainee attempts (1 persona chat per user), so admins/super_admins are
|
||||
# subject to the same per-user lock as trainees when they use the app.
|
||||
if requested_mode == "preview":
|
||||
requested_mode = "trainee"
|
||||
scenario_raw = body.get("scenario", "social")
|
||||
scenario = scenario_raw.strip().lower() if isinstance(scenario_raw, str) else "social"
|
||||
if scenario not in ("social", "f2f_call"):
|
||||
@@ -417,7 +418,7 @@ def send_message(gid: str, pid: str):
|
||||
user_id=actor["id"],
|
||||
group_id=gid,
|
||||
persona_id=pid,
|
||||
mode="preview" if actor.get("role") in ("admin", "super_admin") else "trainee",
|
||||
mode="trainee",
|
||||
)
|
||||
if not session:
|
||||
raise ApiError("no active session for this persona", 404)
|
||||
@@ -558,7 +559,7 @@ def finish_session(gid: str, pid: str):
|
||||
user_id=actor["id"],
|
||||
group_id=gid,
|
||||
persona_id=pid,
|
||||
mode="preview" if actor.get("role") in ("admin", "super_admin") else "trainee",
|
||||
mode="trainee",
|
||||
)
|
||||
if not session:
|
||||
finished = s["sessions"].latest_for_scope(
|
||||
@@ -566,7 +567,7 @@ def finish_session(gid: str, pid: str):
|
||||
user_id=actor["id"],
|
||||
group_id=gid,
|
||||
persona_id=pid,
|
||||
mode="preview" if actor.get("role") in ("admin", "super_admin") else "trainee",
|
||||
mode="trainee",
|
||||
)
|
||||
if finished and finished.get("status") == "finished":
|
||||
_group, persona = _authorize_session_context(
|
||||
@@ -645,7 +646,7 @@ def resume_session(gid: str, pid: str):
|
||||
user_id=actor["id"],
|
||||
group_id=gid,
|
||||
persona_id=pid,
|
||||
mode="preview" if actor.get("role") in ("admin", "super_admin") else "trainee",
|
||||
mode="trainee",
|
||||
)
|
||||
if not session:
|
||||
raise ApiError("no active session for this persona", 404)
|
||||
|
||||
@@ -616,25 +616,29 @@ def list_personas(gid: str):
|
||||
else:
|
||||
# admin: see persona but not the secret/process fields (IP protection)
|
||||
personas = [serialize_persona(p, actor) for p in persona_records if isinstance(p, dict)]
|
||||
# attach per-user status (won/lost/not-tried) for trainees
|
||||
if actor.get("role") == "user":
|
||||
sess = _stores().get("session_store")
|
||||
store = sess.sessions if sess else None
|
||||
mine = (
|
||||
store.where(
|
||||
lambda r: isinstance(r, dict)
|
||||
and r.get("org_id") == actor.get("org_id")
|
||||
and r.get("user_id") == actor["id"]
|
||||
and r.get("group_id") == gid
|
||||
and (r.get("mode") or "trainee") == "trainee"
|
||||
and r.get("status") == "finished"
|
||||
and r.get("outcome") in {"won", "lost"}
|
||||
)
|
||||
if store else []
|
||||
# Attach per-user status (won/lost/not-tried) for EVERY role. The one-shot
|
||||
# rule is per (user, persona): each user may chat a persona once, but the
|
||||
# same persona can be trained by many different users. So my_outcome reflects
|
||||
# THIS user's own finished sessions, regardless of admin/trainee role.
|
||||
# Always set the key (default 'not_tried') so the frontend's all-roles logic
|
||||
# never sees an undefined value; a global super_admin without an org simply
|
||||
# has no org-scoped sessions and correctly shows every persona as untrained.
|
||||
sess = s.get("session_store")
|
||||
store = sess.sessions if sess else None
|
||||
outcome_by_pid = {}
|
||||
if store and isinstance(actor.get("org_id"), str):
|
||||
mine = store.where(
|
||||
lambda r: isinstance(r, dict)
|
||||
and r.get("org_id") == actor.get("org_id")
|
||||
and r.get("user_id") == actor["id"]
|
||||
and r.get("group_id") == gid
|
||||
and (r.get("mode") or "trainee") == "trainee"
|
||||
and r.get("status") == "finished"
|
||||
and r.get("outcome") in {"won", "lost"}
|
||||
)
|
||||
outcome_by_pid = {r.get("persona_id"): r.get("outcome") for r in mine}
|
||||
for p in personas:
|
||||
p["my_outcome"] = outcome_by_pid.get(p.get("id"), "not_tried")
|
||||
for p in personas:
|
||||
p["my_outcome"] = outcome_by_pid.get(p.get("id"), "not_tried")
|
||||
return jsonify({"personas": personas, "tiers": ["A", "B", "C"]})
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
"""Admin Preview Mode must stay outside trainee outcomes and tenant scope."""
|
||||
"""All roles train for real: no preview mode, one-shot trainee sessions.
|
||||
|
||||
Every chat/session is a real trainee attempt (mode='trainee') regardless of
|
||||
role. Requesting 'preview' is ignored and becomes a normal trainee session.
|
||||
A persona may be chatted once per user; the same persona can be trained by
|
||||
many users. Admin/super_admin sessions count toward their own board and the
|
||||
org analytics just like trainee sessions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from scripts.mock_llm import MockLLM
|
||||
@@ -34,29 +41,29 @@ def _ready_group(client, token):
|
||||
return gid, pid
|
||||
|
||||
|
||||
def test_admin_preview_is_explicit_and_excluded_from_aggregates(client, user_store, login):
|
||||
def test_admin_session_is_real_trainee_and_counts(client, user_store, login):
|
||||
super_token = _setup(user_store, login)
|
||||
gid, pid = _ready_group(client, super_token)
|
||||
admin = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="preview-admin",
|
||||
password="preview-admin-password",
|
||||
name="Preview Admin",
|
||||
username="realtrainer-admin",
|
||||
password="realtrainer-admin-password",
|
||||
name="Real Trainer Admin",
|
||||
role="admin",
|
||||
must_setup=False,
|
||||
)
|
||||
admin_token = login(admin["username"], "preview-admin-password")["token"]
|
||||
admin_token = login(admin["username"], "realtrainer-admin-password")["token"]
|
||||
headers = _headers(admin_token)
|
||||
|
||||
started = client.post(
|
||||
f"/api/chat/{gid}/personas/{pid}/chat/start",
|
||||
json={"scenario": "social", "locale": "th", "mode": "preview"},
|
||||
json={"scenario": "social", "locale": "th"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert started.status_code == 200, started.get_json()
|
||||
session = started.get_json()["session"]
|
||||
assert session["mode"] == "preview"
|
||||
# Admins train for real: mode is trainee (one-shot), not a repeatable preview.
|
||||
assert session["mode"] == "trainee"
|
||||
assert session["status"] == "active"
|
||||
|
||||
sent = client.post(
|
||||
@@ -65,57 +72,61 @@ def test_admin_preview_is_explicit_and_excluded_from_aggregates(client, user_sto
|
||||
headers=headers,
|
||||
)
|
||||
assert sent.status_code == 200, sent.get_json()
|
||||
assert sent.get_json()["session"]["mode"] == "preview"
|
||||
assert sent.get_json()["session"]["mode"] == "trainee"
|
||||
|
||||
# The admin's real session is now a normal trainee session: it shows on
|
||||
# their board (no longer excluded as preview was), so the persona is
|
||||
# correctly marked as trained and locked for this admin.
|
||||
board = client.get("/api/me/board", headers=headers)
|
||||
assert board.status_code == 200
|
||||
assert all(item["my_outcome"] != "won" for item in board.get_json()["board"])
|
||||
|
||||
analytics = client.get("/api/analytics", headers=headers)
|
||||
assert analytics.status_code == 200
|
||||
assert analytics.get_json()["overall"]["total_sessions"] == 0
|
||||
item = next((b for b in board.get_json()["board"]
|
||||
if b.get("persona_id") == pid and b.get("group_id") == gid), None)
|
||||
assert item is not None, "admin's trained persona must appear on the board"
|
||||
assert item["my_outcome"] in ("won", "lost")
|
||||
|
||||
|
||||
def test_user_cannot_claim_preview_mode(client, user_store, login):
|
||||
def test_preview_mode_request_becomes_trainee_session(client, user_store, login):
|
||||
super_token = _setup(user_store, login)
|
||||
gid, pid = _ready_group(client, super_token)
|
||||
user = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="preview-user",
|
||||
password="preview-user-password",
|
||||
name="Preview User",
|
||||
username="plain-user",
|
||||
password="plain-user-password",
|
||||
name="Plain User",
|
||||
role="user",
|
||||
must_setup=False,
|
||||
)
|
||||
token = login(user["username"], "preview-user-password")["token"]
|
||||
token = login(user["username"], "plain-user-password")["token"]
|
||||
|
||||
# Requesting 'preview' no longer exists; it is ignored and becomes a real
|
||||
# one-shot trainee session for every role.
|
||||
response = client.post(
|
||||
f"/api/chat/{gid}/personas/{pid}/chat/start",
|
||||
json={"scenario": "social", "mode": "preview"},
|
||||
headers=_headers(token),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.get_json() == {"error": "permission denied"}
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()["session"]["mode"] == "trainee"
|
||||
|
||||
|
||||
def test_admin_preview_cannot_cross_org(client, user_store, login):
|
||||
def test_admin_cannot_start_session_in_another_org(client, user_store, login):
|
||||
super_token = _setup(user_store, login)
|
||||
gid, pid = _ready_group(client, super_token)
|
||||
other_org = user_store.create_org("Other Org")
|
||||
other_admin = user_store.create_user(
|
||||
org_id=other_org["id"],
|
||||
username="other-preview-admin",
|
||||
password="other-preview-password",
|
||||
name="Other Admin",
|
||||
username="other-org-admin",
|
||||
password="other-org-password",
|
||||
name="Other Org Admin",
|
||||
role="admin",
|
||||
must_setup=False,
|
||||
)
|
||||
token = login(other_admin["username"], "other-preview-password")["token"]
|
||||
token = login(other_admin["username"], "other-org-password")["token"]
|
||||
|
||||
response = client.post(
|
||||
f"/api/chat/{gid}/personas/{pid}/chat/start",
|
||||
json={"scenario": "social", "mode": "preview"},
|
||||
json={"scenario": "social"},
|
||||
headers=_headers(token),
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
{{ persona.channel === 'line' ? i18n.t('line') : i18n.t('facebook') }} ·
|
||||
{{ persona.initiation_mode === 'seller' ? i18n.t('sellerInitiated') : i18n.t('customerInitiated') }}
|
||||
</p>
|
||||
<p v-if="auth.isAdmin" class="preview-label">{{ i18n.t('previewMode') }}</p>
|
||||
<div
|
||||
v-for="sc in scenarios"
|
||||
:key="sc.id"
|
||||
@@ -92,7 +91,6 @@ import { computed, onMounted, nextTick, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Target, ArrowLeft, Search, MessageCircle, Phone, Play } from 'lucide-vue-next'
|
||||
import { api } from '../api'
|
||||
import { auth } from '../store/auth'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -107,7 +105,6 @@ const sending = ref(false)
|
||||
const debrief = ref(null)
|
||||
const sessionId = ref(null)
|
||||
const picked = ref('social')
|
||||
const previewMode = computed(() => auth.isAdmin)
|
||||
|
||||
const scenarios = computed(() => {
|
||||
const en = i18n.locale === 'en'
|
||||
@@ -126,7 +123,7 @@ const thread = ref(null)
|
||||
|
||||
async function begin() {
|
||||
try {
|
||||
const res = await api.chatStart(gid, pid, picked.value, i18n.locale, previewMode.value ? 'preview' : 'trainee')
|
||||
const res = await api.chatStart(gid, pid, picked.value, i18n.locale, 'trainee')
|
||||
phase.value = 'chat'
|
||||
sessionId.value = res.session.id
|
||||
messages.value = res.session.messages || []
|
||||
@@ -242,7 +239,6 @@ function fmt(v) {
|
||||
.msg-system { align-self: center; background: #fef3c7; color: #92400e; font-size: 12px; max-width: 88%; border-radius: 999px; }
|
||||
.composer { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.context-line { margin: 8px 0 12px; color: var(--ink); font-size: 13px; }
|
||||
.preview-label { margin: 8px 0 12px; padding: 8px 10px; border-radius: 8px; background: #fef3c7; color: #92400e; font-size: 12px; }
|
||||
.task { margin-bottom: 12px; background: #fff7ed; border-color: #fed7aa; }
|
||||
.debrief { margin-top: 16px; }
|
||||
button.danger { background: var(--red); color: #fff; border: none; }
|
||||
|
||||
@@ -39,25 +39,21 @@
|
||||
</div>
|
||||
<div class="muted" style="margin-top:6px">{{ p.product_context }}</div>
|
||||
|
||||
<template v-if="auth.isAdmin">
|
||||
<!-- One persona = one chat per user (all roles). Untrained -> chat; trained -> summary + variant -->
|
||||
<template v-if="p.my_outcome === 'not_tried'">
|
||||
<router-link :to="`/groups/${gid}/chat/${p.id}`" style="margin-top:auto">
|
||||
<button class="primary" style="width:100%">{{ i18n.t('chat') }}</button>
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link v-if="p.my_outcome === 'not_tried'" :to="`/groups/${gid}/chat/${p.id}`" style="margin-top:auto">
|
||||
<button class="primary" style="width:100%">{{ i18n.t('chat') }}</button>
|
||||
<div class="muted trained" style="margin-top:auto;font-size:12px"><Check :size="14" :stroke-width="1.8" /> {{ i18n.t('trained') }} ({{ outcomeLabel(p.my_outcome) }})</div>
|
||||
<!-- Trained persona: open the chat page to view the past conversation + result -->
|
||||
<router-link :to="`/groups/${gid}/chat/${p.id}`" style="margin-top:8px">
|
||||
<button class="primary" style="width:100%">{{ i18n.t('personaSummary') }}</button>
|
||||
</router-link>
|
||||
<template v-else>
|
||||
<div class="muted trained" style="margin-top:auto;font-size:12px"><Check :size="14" :stroke-width="1.8" /> {{ i18n.t('trained') }} ({{ outcomeLabel(p.my_outcome) }})</div>
|
||||
<!-- Trained persona: open the chat page to view the past conversation + result -->
|
||||
<router-link :to="`/groups/${gid}/chat/${p.id}`" style="margin-top:8px">
|
||||
<button class="primary" style="width:100%">{{ i18n.t('personaSummary') }}</button>
|
||||
</router-link>
|
||||
<button class="soft" style="width:100%;margin-top:8px" @click="makeVariant(p)" :disabled="p._busy">
|
||||
{{ p._busy ? i18n.t('creating') : i18n.t('createVariant') }}
|
||||
</button>
|
||||
</template>
|
||||
<button class="soft" style="width:100%;margin-top:8px" @click="makeVariant(p)" :disabled="p._busy">
|
||||
{{ p._busy ? i18n.t('creating') : i18n.t('createVariant') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user