8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
189 lines
7.6 KiB
PL/PgSQL
189 lines
7.6 KiB
PL/PgSQL
-- Outcome & Alliance OS G4/G5: cross-session self-directed practice runtime.
|
|
-- A prescription is authored in one session; its observed attempt evidence belongs
|
|
-- to a later completed session owned by the same learner.
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_practice_prescription_record_learner
|
|
ON app.practice_prescription(prescription_record_id, learner_id);
|
|
|
|
ALTER TABLE app.practice_episode_submission
|
|
DROP CONSTRAINT IF EXISTS practice_episode_prescription_fkey;
|
|
ALTER TABLE app.practice_episode_submission
|
|
ADD CONSTRAINT practice_episode_prescription_fkey
|
|
FOREIGN KEY (prescription_record_id, learner_id)
|
|
REFERENCES app.practice_prescription(prescription_record_id, learner_id)
|
|
ON DELETE RESTRICT;
|
|
|
|
ALTER TABLE app.practice_curriculum_decision_event
|
|
DROP CONSTRAINT IF EXISTS practice_decision_prescription_fkey;
|
|
ALTER TABLE app.practice_curriculum_decision_event
|
|
ADD CONSTRAINT practice_decision_prescription_fkey
|
|
FOREIGN KEY (selected_prescription_record_id, learner_id)
|
|
REFERENCES app.practice_prescription(prescription_record_id, learner_id)
|
|
ON DELETE RESTRICT;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_practice_episode_transfer_gate()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
attempts JSONB;
|
|
competency_key TEXT;
|
|
has_familiar_pass BOOLEAN;
|
|
has_unseen_pass BOOLEAN;
|
|
has_transfer_collision BOOLEAN;
|
|
claimed_prior_familiar INT;
|
|
durable_prior_familiar INT;
|
|
BEGIN
|
|
IF NEW.assessment_payload->>'progress' IS DISTINCT FROM NEW.progress
|
|
OR COALESCE((NEW.assessment_payload->>'mastery_allowed')::BOOLEAN, FALSE)
|
|
IS DISTINCT FROM NEW.mastery_allowed THEN
|
|
RAISE EXCEPTION 'practice episode columns must match assessment payload'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
attempts := COALESCE(NEW.assessment_payload->'attempts', '[]'::JSONB);
|
|
IF jsonb_typeof(attempts) <> 'array' OR jsonb_array_length(attempts) = 0 THEN
|
|
RAISE EXCEPTION 'practice episode requires attempt evidence'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
|
|
competency_key := NEW.assessment_payload->>'competency_id';
|
|
claimed_prior_familiar := COALESCE(
|
|
(NEW.assessment_payload->>'prior_familiar_demonstrations')::INT,
|
|
0
|
|
);
|
|
SELECT COALESCE((state->>'familiar_demonstrations')::INT, 0)
|
|
INTO durable_prior_familiar
|
|
FROM app.competency_graph_snapshot snapshot,
|
|
jsonb_array_elements(COALESCE(snapshot.graph_payload->'states', '[]'::JSONB)) state
|
|
WHERE snapshot.learner_id = NEW.learner_id
|
|
AND state->>'competency_id' = competency_key
|
|
ORDER BY snapshot.snapshot_no DESC
|
|
LIMIT 1;
|
|
durable_prior_familiar := COALESCE(durable_prior_familiar, 0);
|
|
IF claimed_prior_familiar > durable_prior_familiar THEN
|
|
RAISE EXCEPTION 'practice episode prior familiar claim exceeds durable graph'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(attempts) item
|
|
WHERE item->>'outcome' = 'passed'
|
|
AND item->>'scenario_novelty' = 'familiar'
|
|
) INTO has_familiar_pass;
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(attempts) item
|
|
WHERE item->>'outcome' = 'passed'
|
|
AND item->>'scenario_novelty' = 'unseen_transfer'
|
|
AND length(btrim(COALESCE(item->>'utterance_template_id', ''))) > 0
|
|
) INTO has_unseen_pass;
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(attempts) unseen,
|
|
jsonb_array_elements(attempts) familiar
|
|
WHERE unseen->>'outcome' = 'passed'
|
|
AND unseen->>'scenario_novelty' = 'unseen_transfer'
|
|
AND familiar->>'outcome' = 'passed'
|
|
AND familiar->>'scenario_novelty' = 'familiar'
|
|
AND (
|
|
unseen->>'scenario_variant_id' = familiar->>'scenario_variant_id'
|
|
OR unseen->>'utterance_template_id' = familiar->>'utterance_template_id'
|
|
)
|
|
) INTO has_transfer_collision;
|
|
|
|
IF NEW.progress = 'mastered'
|
|
AND (
|
|
(NOT has_familiar_pass AND claimed_prior_familiar < 1)
|
|
OR NOT has_unseen_pass
|
|
OR has_transfer_collision
|
|
) THEN
|
|
RAISE EXCEPTION 'practice mastery requires durable familiar success and novel unseen transfer evidence'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_practice_attempt_sequence_and_transfer()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
latest_sequence INT;
|
|
prescription_record UUID;
|
|
competency_key TEXT;
|
|
familiar_pass_exists BOOLEAN;
|
|
durable_prior_familiar INT;
|
|
repeated_template_exists BOOLEAN;
|
|
repeated_variant_exists BOOLEAN;
|
|
BEGIN
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.episode_submission_id::TEXT, 0));
|
|
SELECT max(attempt.sequence_no) INTO latest_sequence
|
|
FROM app.practice_attempt_evidence attempt
|
|
WHERE attempt.episode_submission_id = NEW.episode_submission_id;
|
|
IF NEW.sequence_no <> COALESCE(latest_sequence, 0) + 1 THEN
|
|
RAISE EXCEPTION 'practice attempt sequence must be contiguous'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
|
|
IF NEW.outcome = 'passed' AND NEW.scenario_novelty = 'unseen_transfer' THEN
|
|
IF length(btrim(COALESCE(NEW.utterance_template_id, ''))) = 0 THEN
|
|
RAISE EXCEPTION 'unseen transfer pass requires a nonblank utterance template id'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
SELECT episode.prescription_record_id, prescription.competency_id
|
|
INTO prescription_record, competency_key
|
|
FROM app.practice_episode_submission episode
|
|
JOIN app.practice_prescription prescription
|
|
ON prescription.prescription_record_id = episode.prescription_record_id
|
|
AND prescription.learner_id = episode.learner_id
|
|
WHERE episode.episode_submission_id = NEW.episode_submission_id
|
|
AND episode.learner_id = NEW.learner_id;
|
|
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM app.practice_attempt_evidence prior
|
|
JOIN app.practice_episode_submission episode
|
|
ON episode.episode_submission_id = prior.episode_submission_id
|
|
WHERE episode.prescription_record_id = prescription_record
|
|
AND prior.learner_id = NEW.learner_id
|
|
AND prior.scenario_novelty = 'familiar'
|
|
AND prior.outcome = 'passed'
|
|
) INTO familiar_pass_exists;
|
|
SELECT COALESCE((state->>'familiar_demonstrations')::INT, 0)
|
|
INTO durable_prior_familiar
|
|
FROM app.competency_graph_snapshot snapshot,
|
|
jsonb_array_elements(COALESCE(snapshot.graph_payload->'states', '[]'::JSONB)) state
|
|
WHERE snapshot.learner_id = NEW.learner_id
|
|
AND state->>'competency_id' = competency_key
|
|
ORDER BY snapshot.snapshot_no DESC
|
|
LIMIT 1;
|
|
familiar_pass_exists := familiar_pass_exists
|
|
OR COALESCE(durable_prior_familiar, 0) > 0;
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM app.practice_attempt_evidence prior
|
|
JOIN app.practice_episode_submission episode
|
|
ON episode.episode_submission_id = prior.episode_submission_id
|
|
WHERE episode.prescription_record_id = prescription_record
|
|
AND prior.learner_id = NEW.learner_id
|
|
AND prior.scenario_novelty = 'familiar'
|
|
AND prior.utterance_template_id = NEW.utterance_template_id
|
|
) INTO repeated_template_exists;
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM app.practice_attempt_evidence prior
|
|
JOIN app.practice_episode_submission episode
|
|
ON episode.episode_submission_id = prior.episode_submission_id
|
|
WHERE episode.prescription_record_id = prescription_record
|
|
AND prior.learner_id = NEW.learner_id
|
|
AND prior.scenario_novelty = 'familiar'
|
|
AND prior.scenario_variant_id = NEW.scenario_variant_id
|
|
) INTO repeated_variant_exists;
|
|
IF NOT familiar_pass_exists OR repeated_template_exists OR repeated_variant_exists THEN
|
|
RAISE EXCEPTION 'unseen transfer pass cannot reuse familiar variant or memorized phrase'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|