Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
56417e5
refactor(interview): extract competency order and open rule into Comp…
micio86dev Oct 14, 2026
62c3888
feat(interview): add the candidate interview outline endpoint
micio86dev Oct 14, 2026
95f9a3c
chore(api): export openapi for the interview outline
micio86dev Oct 14, 2026
aa2b12a
feat(interview): return primary_index from the utterance endpoint
micio86dev Oct 14, 2026
284a295
feat(avatar-templates): add the PersonaDisplayName validation rule
micio86dev Oct 14, 2026
c1d8fab
fix(avatar-templates): refuse malformed UTF-8 and edge control charac…
micio86dev Oct 14, 2026
76a2a89
feat(avatar-templates): validate and merge persona.display_name on ev…
micio86dev Oct 14, 2026
5cb780c
feat(avatar-templates): expose persona.display_name to admins and cla…
micio86dev Oct 14, 2026
1e6d087
feat(avatar-templates): refuse an invalid persona display name on import
micio86dev Oct 14, 2026
811f68d
fix(interview): document the outline refusals in the OpenAPI spec
micio86dev Oct 14, 2026
16201bd
fix(avatar-templates): close persona display name review gaps
micio86dev Oct 14, 2026
aaf4528
test(avatar-templates): prove an empty persona object still clears th…
micio86dev Oct 14, 2026
f3dc60d
Merge pull request #189 from micio86dev/feature/licc-a1-competency-pr…
micio86dev Oct 15, 2026
a731027
Merge pull request #190 from micio86dev/feature/licc-a2-outline
micio86dev Oct 15, 2026
5c715fc
Merge pull request #191 from micio86dev/feature/licc-a3a-primary-index
micio86dev Oct 15, 2026
6871589
Merge pull request #192 from micio86dev/feature/licc-a3b-persona-rule
micio86dev Oct 15, 2026
25d5f39
Merge pull request #193 from micio86dev/feature/licc-a3c-persona-writes
micio86dev Oct 15, 2026
2efbc7f
Merge pull request #194 from micio86dev/feature/licc-a3-utterance-per…
micio86dev Oct 15, 2026
d6cb1a3
Merge pull request #195 from micio86dev/feature/licc-a4-persona-import
micio86dev Oct 15, 2026
1f65e36
Merge pull request #196 from micio86dev/feature/licc-a5-review-followups
micio86dev Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions app/Actions/Interview/BuildInterviewOutline.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

declare(strict_types=1);

namespace App\Actions\Interview;

use App\Enums\AssessmentType;
use App\Models\Competency;
use App\Models\InterviewSession;
use App\Models\Participant;
use App\Models\Project;
use App\Models\Role;
use App\Support\Catalogue\CatalogueRevisionResolver;
use App\Support\Interview\CompetencyProgress;

/**
* Builds the candidate's interview outline (live-interview-call-chrome D3/D4).
*
* Read-only: it resolves, it never writes. The competency order and the "open" rule come from
* `CompetencyProgress`, the same code `POST /start` uses, so the entry reported `current` is by
* construction the competency the next `/start` serves.
*
* Nothing here returns a competency code or an identifier: the outline feeds a screen the candidate
* reads, and a machine code on it is exactly what the call chrome keeps out.
*
* REQ: interview-session "GET /candidate/interview/outline"
*/
final class BuildInterviewOutline
{
public function __construct(
private readonly CompetencyProgress $progress,
private readonly CatalogueRevisionResolver $revisions,
private readonly ResolvePrimaryQuestions $primaries,
) {}

/**
* @return array{assessment_type: string, role_name: string|null, avatar_name: string|null, candidate_name: string|null, primary_total_all: int, competencies: list<array{name: string|null, ordinal: int, status: string, primary_total: int}>}
*/
public function handle(Participant $participant, Project $project, AssessmentType $type): array
{
$locale = $project->language ?? (string) config('app.fallback_locale', 'en');
$ordered = $this->progress->ordered($project->id)->values();

$sessions = InterviewSession::where('participant_id', $participant->id)
->where('project_id', $project->id)
->get(['competency_code', 'status', 'error_count', 'primary_questions'])
->keyBy('competency_code');

// Revision-unique ids: no code lookup and no resolver call is needed for the names.
$competencies = Competency::whereIn('id', $ordered->pluck('competency_id'))->get()->keyBy('id');

$entries = [];
$currentFound = false;

foreach ($ordered as $index => $row) {
$session = $sessions->get($row->competency_code);
$open = CompetencyProgress::isOpen($session);

$status = match (true) {
$open && ! $currentFound => 'current',
$open && $session !== null => 'retry',
$session === null => 'upcoming',
default => 'done',
};
$currentFound = $currentFound || $status === 'current';

$competency = $competencies->get($row->competency_id);

$entries[] = [
'name' => $competency === null ? null : $this->localizedName($competency, $locale),
'ordinal' => $index + 1,
'status' => $status,
'primary_total' => $this->primaryTotal($project, (int) $row->competency_id, $session),
];
}

return [
'assessment_type' => $type->value,
'role_name' => $type === AssessmentType::Standard ? $this->roleName($project, $locale) : null,
'avatar_name' => $project->avatarTemplate?->personaDisplayName(),
'candidate_name' => $this->nonBlank($participant->display_name),
'competencies' => $entries,
'primary_total_all' => array_sum(array_column($entries, 'primary_total')),
];
}

/**
* What the system will ask: the count frozen in the session when one exists (what the
* classifier counts against), otherwise the count it would freeze.
*/
private function primaryTotal(Project $project, int $competencyId, ?InterviewSession $session): int
{
if ($session?->primary_questions !== null) {
return count($session->primary_questions);
}

// ponytail: per-competency primaries query (at most ~18 small indexed reads), batch into one
// grouped read if the outline shows up in p95.
return count($this->primaries->handle($project, $competencyId));
}

private function roleName(Project $project, string $locale): ?string
{
if ($project->role_code === null) {
return null;
}

$revisionId = $this->revisions->tryForProject($project);

if ($revisionId === null) {
return null;
}

$role = Role::where('code', $project->role_code)->where('revision_id', $revisionId)->first();

return $role === null ? null : $this->localizedName($role, $locale);
}

/**
* The project language, then the catalogue default; never the code (V3).
*/
private function localizedName(Competency|Role $model, string $locale): ?string
{
foreach ([$locale, (string) config('app.fallback_locale', 'en')] as $candidate) {
$name = $this->nonBlank($model->getTranslation('name', $candidate, false));

if ($name !== null) {
return $name;
}
}

return null;
}

private function nonBlank(mixed $value): ?string
{
$trimmed = is_string($value) ? trim($value) : '';

return $trimmed === '' ? null : $trimmed;
}
}
4 changes: 2 additions & 2 deletions app/Actions/Interview/SettleParticipantCompletion.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@ final class SettleParticipantCompletion
* participant stranded before this change who comes back.
*
* THE DEFECT THIS REPAIRS: the tally counted only `completed|timeout|skipped`
* while `resolveNextCompetency()` treats `error` as terminal and skips it. A
* while `CompetencyProgress::resolveNext()` treats `error` as terminal and skips it. A
* participant with one errored competency exhausted every competency while the
* count stayed at `total - 1`, so the CAS never fired — no scoring, no webhook,
* no notification, and nothing anywhere reported it.
*
* An `error` counts only once it has spent its re-offer. This predicate and the
* re-offer branch in `resolveNextCompetency()` are two halves of ONE behaviour
* re-offer branch in `CompetencyProgress::resolveNext()` are two halves of ONE behaviour
* and cannot ship apart: count errors too early and a single transient provider
* 4xx — our own bug — ends the interview with no second chance; count them too
* late and a competency the resolver skips is never tallied, which is the
Expand Down
2 changes: 1 addition & 1 deletion app/Actions/Participant/RecoverFailedParticipant.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
* `InterviewSession` at status=error for that participant (-> pending,
* cleared, utterances deleted) inside one `DB::transaction()` + row lock.
* Sessions at completed/timeout/skipped are NEVER touched — the existing
* `InterviewController::resolveNextCompetency()` skip logic still treats
* `CompetencyProgress::resolveNext()` skip logic still treats
* them as terminal-completed, so the candidate resumes at the failed
* competency, never re-asked anything already answered.
*
Expand Down
4 changes: 4 additions & 0 deletions app/Http/Controllers/Api/PlatformAvatarTemplateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ public function store(Request $request): JsonResponse

$validated = $request->validate($this->templateStoreRules());

$validated = array_merge($validated, $this->personaWrite($request, null));

$this->assertConfigValid($validated['provider'], $validated['config']);
app(BindHeygenTemplateVoice::class)->run($validated['provider'], $validated['config']);
$this->assertNameFreeAmong(AvatarTemplate::platformOnly(), $validated['name'], null);
Expand Down Expand Up @@ -195,6 +197,8 @@ public function update(Request $request, int $id): PlatformAvatarTemplateResourc
$template = AvatarTemplate::platformOnly()->findOrFail($id);
$validated = $request->validate($this->templateUpdateRules());

$validated = array_merge($validated, $this->personaWrite($request, $template->persona));

// The provider is immutable: the config's knobs belong to it, so a
// template that changed provider would validate as empty and quietly
// fall back to the environment defaults.
Expand Down
4 changes: 4 additions & 0 deletions app/Http/Controllers/AvatarTemplateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ public function store(Request $request): JsonResponse

$validated = $request->validate($this->templateStoreRules());

$validated = array_merge($validated, $this->personaWrite($request, null));

$this->assertConfigValid($validated['provider'], $validated['config']);
// A superadmin's external voice is bound here, before the row is written,
// exactly as on the platform route: the ledger and secrets are platform
Expand Down Expand Up @@ -260,6 +262,8 @@ public function update(Request $request, int $id): AvatarTemplateResource

$validated = $request->validate($this->templateUpdateRules());

$validated = array_merge($validated, $this->personaWrite($request, $template->persona));

// The provider is immutable. Changing it would leave every knob in the
// config belonging to the other one — avatarId where faceId is
// expected, with nothing overlapping — so the template would validate
Expand Down
11 changes: 11 additions & 0 deletions app/Http/Controllers/AvatarTemplatePortabilityController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Models\AvatarTemplate;
use App\Models\LlmCredential;
use App\Models\LlmModel;
use App\Rules\PersonaDisplayName;
use App\Support\AvatarTemplates\ConfigValidator;
use App\Support\AvatarTemplates\ProviderFieldSpecs;
use App\Support\AvatarTemplates\TemplateDocument;
Expand Down Expand Up @@ -124,6 +125,16 @@ private function reject(array $record): ?string
return "Unknown provider '{$record['provider']}'.";
}

// The SAME rule the form applies to the interviewer's name: a file must never install a
// name the form would refuse. The message never repeats a matched vendor word.
if (isset($record['persona']['display_name'])) {
$nameProblem = PersonaDisplayName::problem($record['persona']['display_name']);

if ($nameProblem !== null) {
return "persona.display_name: {$nameProblem}";
}
}

// The SAME validator the create/edit form is built from (D7). It
// already refuses unknown keys, so this must not grow a second check:
// two validators drift, and the divergence would let a file install a
Expand Down
108 changes: 5 additions & 103 deletions app/Http/Controllers/Candidate/InterviewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
use App\Services\Provider\TavusProvider;
use App\Support\Catalogue\CatalogueRevisionResolver;
use App\Support\Interview\AvatarSilenceDetector;
use App\Support\Interview\CompetencyProgress;
use App\Support\Interview\ProviderRefLifetime;
use App\Support\Interview\SessionLiveClock;
use App\Support\Interview\SharedProviderRefGuard;
Expand Down Expand Up @@ -101,6 +102,7 @@ public function __construct(
private readonly SharedProviderRefGuard $siblings,
private readonly AdvanceOnLiveConversation $advanceOnLiveConversation,
private readonly ProviderRefLifetime $refLifetime,
private readonly CompetencyProgress $competencyProgress,
) {}

// =========================================================================
Expand Down Expand Up @@ -238,7 +240,7 @@ public function start(Request $request): JsonResponse
return response()->json(['error' => 'project_not_found'], Response::HTTP_UNPROCESSABLE_ENTITY);
}

$nextCompetency = $this->resolveNextCompetency($pid, $project->id);
$nextCompetency = $this->competencyProgress->resolveNext($pid, $project->id);

if ($nextCompetency === null) {
$this->settleCompletionIfFinished($pid, $project->id);
Expand Down Expand Up @@ -623,7 +625,7 @@ public function suspend(Request $request): JsonResponse
* status ∈ {completed, timeout, skipped}, OR status = 'error' with
* error_count >= MAX_ERROR_ATTEMPTS. That last disjunct is not optional —
* settleCompletionIfFinished()'s docblock below explains at length that a
* tally disagreeing with resolveNextCompetency() is what stranded participants.
* tally disagreeing with CompetencyProgress::resolveNext() is what stranded participants.
* (8) Last-question CAS: Participant::where(id, status=in_corso)->update(in_valutazione).
* Only if $won === 1: dispatch FinalizeInterview::dispatch($pid)->afterCommit().
* (9) COMMIT. Return 200.
Expand Down Expand Up @@ -901,12 +903,7 @@ private function freshRemainingCompetencies(int $participantId, int $projectId,
->pluck('competency_code')
->all();

$all = DB::table('project_competencies as pc')
->join('framework_competencies as fc', 'fc.id', '=', 'pc.competency_id')
->where('pc.project_id', $projectId)
->orderBy('pc.position')
->pluck('fc.code')
->values();
$all = $this->competencyProgress->ordered($projectId)->pluck('competency_code')->values();

$remaining = [];

Expand Down Expand Up @@ -972,101 +969,6 @@ private function activeInCorsoSession(int $participantId, string $competencyCode
->first();
}

/**
* Resolve the next competency for this participant (lowest position not yet terminal-completed).
*
* terminal-completed = status ∈ {completed, timeout, skipped}
* pending | in_corso → RESUME that session (no new creation needed).
*
* The ARRAY INDEX of this ordered list, not `position`, is what `competency_ordinal` is
* derived from (D6). `question_index` is persisted and equals `position` verbatim;
* `competency_ordinal` is derived per request and always dense (1..N), never stored. The
* two coincide for a dense, unreordered project, a consequence of well-formed data, not
* the reason `competency_ordinal` exists.
*
* The re-offer branch (D2) is ordered AFTER the pending|in_corso branch: a session already
* reset to `pending` is caught above and never reaches it, so the ratified participant-sso
* "Resume, not restart" scenario holds verbatim (the operator recovery path leaves sessions
* at `pending`, never at `error`). The reset happens at the call site, not here: this
* method resolves, it does not mutate.
*
* @return array{competency_code: string, question_index: int, competency_ordinal: int, total_competencies: int, reoffer?: bool}|null
*
* @internal
* All competencies ordered by position. competency_ordinal is the array index (D6), not position.
* Status and attempts spent in ONE query (the design promised one; sdd-verify caught the drift to two).
* No session yet → this is the next one to create
* pending | in_corso → RESUME this competency
* (D2) error with attempts left → RE-OFFER (ordered after pending|in_corso; see docblock).
* completed | timeout | skipped | exhausted error → skip to next
*/
private function resolveNextCompetency(int $participantId, int $projectId): ?array
{
$all = DB::table('project_competencies as pc')
->join('framework_competencies as fc', 'fc.id', '=', 'pc.competency_id')
->where('pc.project_id', $projectId)
->orderBy('pc.position')
->select('fc.code as competency_code', 'pc.position')
->get();

$existing = InterviewSession::where('participant_id', $participantId)
->where('project_id', $projectId)
->get(['competency_code', 'status', 'error_count'])
->keyBy('competency_code');

$total = $all->count();

foreach ($all->values() as $index => $row) {
$session = $existing->get($row->competency_code);
$status = $session?->status;

if ($session === null) {
return $this->competencyPayload((array) $row, $index, $total);
}

if (in_array($status, ['pending', 'in_corso'], true)) {
return $this->competencyPayload((array) $row, $index, $total);
}

if ($status === 'error'
&& $session->error_count < InterviewSession::MAX_ERROR_ATTEMPTS
) {
return $this->competencyPayload((array) $row, $index, $total, reoffer: true);
}

}

return null; // all competencies terminal-completed
}

/**
* Build the array `resolveNextCompetency()` returns for one row (D5).
*
* Collapses the three near-identical literals that used to live at the three
* return sites above into one construction. `question_index` MUST equal
* `$row->position` verbatim (D1) — `position` is 0-based at every writer, so
* the value needs no arithmetic here; the historic `- 1` produced `-1` for a
* project's first competency.
*
* @param array<string, mixed> $row cast from the `project_competencies` ⋈ `framework_competencies` row
* @return array{competency_code: string, question_index: int, competency_ordinal: int, total_competencies: int, reoffer?: bool}
*/
private function competencyPayload(array $row, int $index, int $total, bool $reoffer = false): array
{
$payload = [
'competency_code' => (string) $row['competency_code'],
'question_index' => (int) $row['position'],
'competency_ordinal' => $index + 1,
'total_competencies' => $total,
];

if ($reoffer) {
$payload['reoffer'] = true;
}

return $payload;
}

/**
* Create a new session row or re-query an existing one (RESUME path).
*
Expand Down
Loading
Loading