diff --git a/apps/api/app/persona_repository.py b/apps/api/app/persona_repository.py
index c6c32b9..1686357 100644
--- a/apps/api/app/persona_repository.py
+++ b/apps/api/app/persona_repository.py
@@ -10,6 +10,7 @@ from __future__ import annotations
import json
import uuid
from dataclasses import dataclass
+from pathlib import Path
from typing import Any, Iterable, Literal, cast
from .config import settings
@@ -18,6 +19,7 @@ from .services.persona import PersonaCard, SEED_PERSONAS, get_seed_persona
SEED_VERSION = 1
+REPO_PERSONA_DIR = Path(__file__).resolve().parents[3] / "data" / "personas"
PersonaStatus = Literal["draft", "review", "approved", "archived"]
_CARD_COLUMNS = """
@@ -70,6 +72,46 @@ def seed_persona_id(code: str) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:persona:{code.upper()}"))
+def _card_from_payload(payload: dict[str, Any]) -> PersonaCard:
+ return PersonaCard(
+ code=str(payload["code"]).strip().upper(),
+ display_name=str(payload["display_name"]),
+ difficulty=str(payload["difficulty"]),
+ theory_target=_string_list(payload.get("theory_target")),
+ demographics=_json_dict(payload.get("demographics")),
+ presenting=_json_dict(payload.get("presenting")),
+ history=_json_dict(payload.get("history")),
+ big5=_json_dict(payload.get("big5")),
+ resistance=_json_dict(payload.get("resistance")),
+ speech_style=_json_dict(payload.get("speech_style")),
+ affect_baseline=_json_dict(payload.get("affect_baseline")),
+ ccd=_json_dict(payload.get("ccd")),
+ dsm5_dimensional=_json_dict(payload.get("dsm5_dimensional")),
+ source_provenance=str(payload.get("source_provenance") or ""),
+ is_synthetic=bool(payload.get("is_synthetic", True)),
+ )
+
+
+def load_file_personas(persona_dir: Path = REPO_PERSONA_DIR) -> list[PersonaCard]:
+ """Load repository-managed persona cards that are not hard-coded in persona.py."""
+ if not persona_dir.exists():
+ return []
+ cards: list[PersonaCard] = []
+ for path in sorted(persona_dir.glob("P*.json")):
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(payload, dict):
+ cards.append(_card_from_payload(payload))
+ return cards
+
+
+def built_in_personas() -> list[PersonaCard]:
+ """Return deterministic built-in catalog cards from code seeds plus repo JSON cards."""
+ cards: dict[str, PersonaCard] = {card.code.upper(): card for card in SEED_PERSONAS.values()}
+ for card in load_file_personas():
+ cards.setdefault(card.code.upper(), card)
+ return [cards[code] for code in sorted(cards)]
+
+
def _json_dict(value: Any) -> dict[str, Any]:
if value is None:
return {}
@@ -157,7 +199,10 @@ def persona_draft_record_from_row(row: Any) -> PersonaDraftRecord:
def seed_fallback_persona(code: str) -> CatalogPersona | None:
- card = get_seed_persona(code)
+ normalized = code.strip().upper()
+ card = get_seed_persona(normalized)
+ if card is None:
+ card = next((entry for entry in load_file_personas() if entry.code.upper() == normalized), None)
if card is None:
return None
return CatalogPersona(
@@ -178,7 +223,7 @@ def seed_fallback_personas() -> list[CatalogPersona]:
source="seed_fallback",
degraded=True,
)
- for card in SEED_PERSONAS.values()
+ for card in built_in_personas()
]
@@ -187,7 +232,7 @@ async def materialize_seed_personas() -> int:
get_pool()
count = 0
async with acquire(role="admin") as conn:
- for card in SEED_PERSONAS.values():
+ for card in built_in_personas():
await conn.execute(
"""
INSERT INTO app.persona_card (
@@ -574,6 +619,7 @@ __all__ = [
"PersonaReviewAction",
"PersonaStatus",
"SEED_VERSION",
+ "built_in_personas",
"card_from_row",
"catalog_persona_from_row",
"create_persona_draft",
@@ -583,6 +629,7 @@ __all__ = [
"list_approved_personas",
"list_catalog_personas",
"list_persona_review_queue",
+ "load_file_personas",
"materialize_seed_personas",
"persona_draft_record_from_row",
"persona_review_item_from_row",
diff --git a/apps/api/app/test_persona_review.py b/apps/api/app/test_persona_review.py
index 1bc5de9..e964256 100644
--- a/apps/api/app/test_persona_review.py
+++ b/apps/api/app/test_persona_review.py
@@ -208,6 +208,36 @@ def _draft_payload(
class PersonaApprovalBoundaryTest(unittest.IsolatedAsyncioTestCase):
+ async def test_repository_loads_repo_persona_files_for_p4_to_p7(self) -> None:
+ cards = persona_repository.load_file_personas()
+
+ self.assertEqual([card.code for card in cards], ["P4", "P5", "P6", "P7"])
+ self.assertEqual(cards[0].display_name, "하늘(가명) · 고2 · 학업/시험 불안")
+ self.assertEqual(cards[0].difficulty, "easy")
+ self.assertIn("cbt", cards[0].theory_target)
+
+ async def test_seed_fallback_includes_repo_persona_files(self) -> None:
+ entry = persona_repository.seed_fallback_persona("p4")
+
+ self.assertIsNotNone(entry)
+ assert entry is not None
+ self.assertEqual(entry.card.code, "P4")
+ self.assertEqual(entry.source, "seed_fallback")
+ self.assertTrue(entry.degraded)
+
+ async def test_materialize_seed_personas_upserts_code_and_repo_file_cards(self) -> None:
+ conn = _PersonaCardConn([])
+
+ with (
+ patch.object(persona_repository, "get_pool", return_value=object()),
+ patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
+ ):
+ count = await persona_repository.materialize_seed_personas()
+
+ self.assertEqual(count, 7)
+ inserted_codes = [args[1] for _, args in conn.execute_calls]
+ self.assertEqual(inserted_codes, ["P1", "P2", "P3", "P4", "P5", "P6", "P7"])
+
async def test_catalog_repository_lists_only_approved_personas(self) -> None:
conn = _PersonaCardConn(
[
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index e5f36c9..b2e571a 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -9,10 +9,16 @@ const nodeEnv = (
process?: { env?: { VITE_ALLOWED_HOSTS?: string; VITE_API_PROXY_TARGET?: string } };
}
).process?.env;
-const allowedHosts = (nodeEnv?.VITE_ALLOWED_HOSTS ?? "")
- .split(",")
- .map((host: string) => host.trim())
- .filter(Boolean);
+const defaultAllowedHosts = ["alpaca-home.taile93291.ts.net"];
+const allowedHosts = Array.from(
+ new Set([
+ ...defaultAllowedHosts,
+ ...(nodeEnv?.VITE_ALLOWED_HOSTS ?? "")
+ .split(",")
+ .map((host: string) => host.trim())
+ .filter(Boolean),
+ ]),
+);
const apiProxyTarget = nodeEnv?.VITE_API_PROXY_TARGET?.trim() || "http://127.0.0.1:8000";
export default defineConfig({
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index d7268bb..a0bfc4c 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -26,6 +26,7 @@
- Phase3 artifact checker 강화: `scripts/check-phase3-artifacts.py`가 CSV enum, KPI report 필수 field, approved export의 PII pass·κ/ICC·withdrawn exclusion·consent scope·file sha256을 검증한다. `app/test_phase3_artifact_checker.py` 5 tests 추가. 실제 파일럿 evidence와 steward/legal/IAA gate는 그대로 외부 의존이다.
- 세션 종료 UX/dark theme SSOT 정리: 드래그형 `SlideToEnd`를 명시 확인 다이얼로그로 교체하고, Topbar/Settings theme 토글을 `lib/theme.ts` 단일 경로로 통합했다. 저장값이 없으면 dark를 기본으로 두고 앱 부팅 시 theme를 먼저 적용해 초기 flash를 줄인다. API 기본 preference `system`은 Settings에서 light로 오해하지 않고 현재 초기 테마를 따른다. 회기 리뷰·설정 포함 주요 페이지의 흰 섹션 잔재도 dark 작업면으로 맞췄다. 검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/layout-visual-gate.spec.ts e2e/settings.spec.ts e2e/session-review.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1` 22 passed.
- M3 인증 claim 1차: Google/SAML/dev-login이 설정 기반 cohort map과 SAML cohort claim을 `cohort_ids`로 넘기고, 관리 사용자 `external_id`는 provider subject 기반(`google:`/`saml:`/`dev:`)으로 저장한다. 운영 SAML 서명검증·기관 claim schema·deprovisioning audit은 후속.
+- 페르소나 저작/P4~P7 적재 2차: `data/personas/P4.json`~`P7.json`을 저장소 관리 `PersonaCard`로 읽어 `materialize_seed_personas()`와 seed fallback catalog에 포함했다. 검증: `python -m pytest app/test_persona_review.py -q` 22 passed, 로컬 dev DB materialize 결과 `approved_codes=P1,P2,P3,P4,P5,P6,P7`, 인증된 로컬 `GET /personas`도 `P1..P7` 반환.
- Google OAuth 진단 1차: provider callback error는 `access_denied`/`provider_error`로 분리하고, 로그인 화면은 실패 reason code를 함께 표시한다. 실제 Google 계정 완료 proof는 아직 owner 로그인/storageState가 필요하다.
- `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다.
- 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다.
@@ -33,7 +34,7 @@
- 2026-06-27 05:34:32 UTC 모바일 502는 cloudflared 로그상 `127.0.0.1:8001` origin connection refused와 일치한다. 현재 public API는 복구됐고, watchdog 기본 검사에서 아직 DNS가 없는 `api-vnet.18ka.net`을 제외해 향후 설치 시 불필요한 restart loop를 막았다. 2026-06-27 18:25 KST 재확인: `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public Google start 302 to `accounts.google.com` + HttpOnly state cookie, provider callback error → `/login?oauth=access_denied`.
- `GET /personas`는 이제 인증 필요다. 검증 당시 로컬 `http://127.0.0.1:8000/personas`와 현재 공개 `https://api-vignette.chanpaca.net/personas` 모두 비로그인 401 확인 완료.
- 로컬 웹이 `http://127.0.0.1:5175`처럼 다른 Vite 포트로 떠도 로그인은 `로컬 테스트 계정으로 계속` 버튼을 사용한다. Google OAuth 버튼은 로컬에서는 disabled로 둔다. 현재 OAuth callback이 공개 API로 돌아가기 때문에 로컬 Google OAuth는 로컬 세션에 붙을 수 없다.
-- 현재 확인용 프로세스는 `127.0.0.1:9099` engine gateway, `127.0.0.1:8001` prod public API, `127.0.0.1:8000` dev API, `127.0.0.1:8010` Tailnet dev API, `127.0.0.1:5173` Vite web, `cloudflared` tunnel 1개다. Tailnet URL은 `https://alpaca-home.taile93291.ts.net/login`이다. stale Vite allowedHosts로 403이 났던 상태는 `scripts/start-tailscale-runtime.ps1` 재기동 후 `login` 200, `/api/health` dev/db/engine true, `/api/auth/config` 200으로 재검증됐다.
+- 현재 확인용 프로세스는 `127.0.0.1:9099` engine gateway, `127.0.0.1:8001` prod public API, `127.0.0.1:8000` dev API, `127.0.0.1:8010` Tailnet dev API, `127.0.0.1:5173` Vite web, `cloudflared` tunnel 1개다. Tailnet URL은 `https://alpaca-home.taile93291.ts.net/login`이다. stale Vite allowedHosts로 403이 났던 상태는 재검증 시 `login` 200, `/api/health` dev/db/engine true, `/api/auth/config` 200이었다. `alpaca-home.taile93291.ts.net`는 Vite 기본 `allowedHosts`에도 포함했다.
- `scripts/dev-up.ps1 -NoGateway -NoWeb`는 이제 gateway/web stale 정리를 건너뛰고 지정 `-ApiPort`의 API만 재기동한다. 검증 당시 8000 API-only 재기동 후 8001/8010/5173/9099/20241 listener가 보존됐다.
- Docker `vignette-dev-db`는 실행 중이고 DB는 accepting connections다. 단, 기존 컨테이너라 healthcheck가 없고 `POSTGRES_USER=vignette` 기반이다. `vignette_app` role은 NOBYPASSRLS로 존재하지만 현재 API startup DDL이 owner 권한을 요구하므로 런타임 app-role 전환은 마이그레이션 owner/런타임 role 분리 후 진행한다.
- 공개 런타임 재기동 스크립트:
@@ -204,7 +205,7 @@
- 2026-06-26 15:18 KST 기준 `apps/web/node_modules/.tmp/public-auth.json`은 없다. codegen 창/프로세스가 남아 있으면 로그인 완료 후 창을 닫아 저장을 완료하면 된다.
- 공개 API `environment:"dev"` 문제는 이번 세션에서 해결했다. cloudflared가 prod API인 `127.0.0.1:8001`을 보도록 변경했고 public gate로 확인했다.
- Google Console 설정은 브라우저 로그인 세션이 필요하다. 막히면 사용자가 로그인해 주기로 했다.
-- 현재 worktree는 문서 상태 업데이트만 남아 있다. 관련 없는 사용자 변경을 되돌리지 말 것.
+- 현재 worktree에는 별도 아바타/세션 화면 미커밋 변경이 남아 있을 수 있다(`Session.tsx`, `session.css`, `apps/web/public/avatar/p1-concept/`). 관련 없는 사용자 변경을 되돌리거나 다른 커밋에 섞지 말 것.
## 다음 세션 우선순위
diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html
index 0158dd7..4972cd9 100644
--- a/docs/dev_dashboard.html
+++ b/docs/dev_dashboard.html
@@ -592,8 +592,8 @@
현재
텍스트 /turn·/stream·음성 경로에서 fast-loop 턴 평가를 TurnRecord.evaluation에만 남기지 않고 app.feedback_scores, app.turn_technique, app.turn_client_state, app.supervisor_comment, app.alternative_utterance로 정규화 적재 후 리뷰 조회에서 hydrate한다. 원시 평가 테이블은 evaluator/admin RLS로 제한하고 서버가 evaluator 컨텍스트로 적재한다. app.turns insert도 RETURNING id 확인으로 0-row silent success를 차단. 검증: pytest app/test_evaluation_persistence.py -q 6 passed, pytest app/test_session_turn_persistence.py app/test_evaluation_persistence.py -q 21 passed, pytest app/ 119 passed + pytest engine_gateway/ 7 passed.
잔여
더 큰 finalize_turn/SessionRepository 경계 · few-shot 골든셋 기본 OFF · 회기리뷰 2열 UI(축어록 타임라인+비언어) 프론트 강화 · 원천 축어록 골든셋 적재(임상팀).
-
-
현재
POST/GET/PUT /personas/drafts를 추가해 teacher/admin이 페르소나 카드를 draft로 생성·조회·편집하고 review 상태로 올릴 수 있다. 교수 콘솔에는 JSON 초안 패널과 검수 큐의 편집 불러오기를 연결했다. 승인된 카드만 학습자 catalog에 노출되는 기존 경계는 유지.
검증
pytest app/test_persona_review.py -q 19 passed, pytest app/ -q 119 passed, npm run check:api-types, npm run typecheck, npm run build passed.
잔여
P4~P7 실제 임상 콘텐츠 적재, JSON 대신 항목형 저작 UI, 루브릭·이론 콘텐츠 외부화는 후속. 임상팀이 제공하지 않은 페르소나 내용을 임의 생성하지 않는다.
+
+
현재
POST/GET/PUT /personas/drafts를 추가해 teacher/admin이 페르소나 카드를 draft로 생성·조회·편집하고 review 상태로 올릴 수 있다. 교수 콘솔에는 JSON 초안 패널과 검수 큐의 편집 불러오기를 연결했다. 승인된 카드만 학습자 catalog에 노출되는 기존 경계는 유지. 이번 2차에서는 data/personas/P4.json~P7.json를 PersonaCard로 읽어 materialize_seed_personas()와 seed fallback catalog에 포함했다.
검증
python -m pytest app/test_persona_review.py -q 22 passed. 로컬 dev DB materialize_seed_personas() 결과 approved_codes=P1,P2,P3,P4,P5,P6,P7, 인증된 로컬 GET /personas도 P1..P7 반환.
잔여
JSON 대신 항목형 저작 UI, 루브릭·이론 콘텐츠 외부화, 임상팀 최종 검수/서면 evidence는 후속. 임상팀이 제공하지 않은 페르소나 내용을 임의 생성하지 않는다.
@@ -609,7 +609,7 @@
-
즉시 착수 가능(내부 코드, 외부 합의 불요)
C1/C2/C3/H2/H3은 1차 구조 완료. 후속은 저장형 워크시트, 임상 스크립트, CBT 체인, 2열 리뷰 UI, P4~P7 콘텐츠 적재처럼 데이터·정책·UI 고도화 영역이다.
H4(부분): 한국어 정규식 마스킹, metadata-only LLM call audit 경로, 로컬 live row proof, learner 동의 수락/철회/회기 시작 하드게이트 골격은 완료. 남은 내부 후보는 이름/기관 NER이고, guardian/legal 서명 동의 evidence는 외부 게이트다. X1 dry-run export 1차와 X2 비용 관측·예산 경고/evaluator 모델 override 1차는 완료, X2 semantic cache는 후속. M1/M2/M3 1차는 완료, provider 기반 한숨·울음 감지와 case_digest/pinned_fact 실적재, 운영 SAML 서명검증은 후속.
H4(부분): 한국어 정규식 마스킹, metadata-only LLM call audit 경로, 로컬 live row proof, learner 동의 수락/철회/회기 시작 하드게이트 골격은 완료. 남은 내부 후보는 이름/기관 NER이고, guardian/legal 서명 동의 evidence는 외부 게이트다. X1 dry-run export 1차와 X2 비용 관측·예산 경고/evaluator 모델 override 1차는 완료, X2 semantic cache는 후속. M1/M2/M3 1차는 완료, provider 기반 한숨·울음 감지와 case_digest/pinned_fact 실적재, 운영 SAML 서명검증은 후속.
2026-06-27 18:25 KST 재확인: public login 200, public API health prod/db/engine true, public Google start 302 to accounts.google.com with HttpOnly state cookie, provider callback error → /login?oauth=access_denied. Tailnet 403은 stale Vite allowedHosts 문제였고 start-tailscale-runtime.ps1 재기동 후 Tailnet login 200 + /api/health dev/db/engine true + /api/auth/config 200. Docker vignette-dev-db accepting connections, 기존 컨테이너 healthcheck 없음.
2026-06-27 18:57 KST 재확인: Tailnet login 200, env 없는 임시 Vite host-header smoke 200, /api/health dev/db/engine true, /api/auth/config dev_login true. alpaca-home.taile93291.ts.net는 Vite 기본 allowedHosts에도 포함해 스크립트 밖 수동 Vite 기동 시 403 재발 가능성을 낮췄다. 기존 public 재확인: public login 200, public API health prod/db/engine true, public Google start 302 to accounts.google.com with HttpOnly state cookie, provider callback error → /login?oauth=access_denied. Docker vignette-dev-db accepting connections, 기존 컨테이너 healthcheck 없음.