feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
|
|
@ -126,6 +126,9 @@ DB readiness는 auth/admin 테이블뿐 아니라 세션 read-model 핵심 테
|
|||
- **`run_turn_stream(ctx, engine, *, log_hook=None)`** (4~8, 기본 UX 경로):
|
||||
게이트웨이 SSE 원시 라인을 받아 `token | done | safety | error`로 재방출.
|
||||
출력 가드레일은 *누적 텍스트* 기준으로 수단정보를 스캔하고, 발견 시 `safety` 이벤트 + 안전 대체로 종결한다.
|
||||
세션 라우트는 client 응답과 결정론 상태를 먼저 영속화하고 `done`을 방출한다. fast-loop evaluator는
|
||||
백그라운드 태스크로 실행해 같은 learner turn의 normalized 평가 row를 교체 저장하며, 평가 기반 코칭 충전도
|
||||
이 태스크가 한 번만 적용한다. 따라서 보이지 않는 평가 지연이 다음 학습자 전송을 잠그지 않는다.
|
||||
|
||||
`TurnContext`/`TurnResult`/`StreamEvent` dataclass가 파이프라인을 관통한다. `EvalHook`/`LogHook`은
|
||||
`Callable[[TurnContext, str], Awaitable[...]]` 타입으로 주입된다.
|
||||
|
|
@ -300,7 +303,7 @@ RBAC×AIView로 차단된다. 이 모듈은 평가 신호만 산출한다.
|
|||
|
||||
### 2.8 음성 캐스케이드 — `app/services/voice.py` + `app/routes/voice.py`
|
||||
|
||||
OpenAI STT/TTS 어댑터(순수 변환 + voice preset 매핑). 상담 로직은 라우트가 조립한다.
|
||||
STT/TTS 어댑터(순수 변환 + voice preset 매핑). 상담 로직은 라우트가 조립한다.
|
||||
|
||||
- STT: `/audio/transcriptions` (gpt-4o-transcribe → 404 시 whisper-1 폴백), 언어 힌트 `ko`.
|
||||
- TTS: `/audio/speech` (gpt-4o-mini-tts → tts-1 폴백). 음성 선택 우선순위는 명시 query preset →
|
||||
|
|
@ -309,12 +312,16 @@ OpenAI STT/TTS 어댑터(순수 변환 + voice preset 매핑). 상담 로직은
|
|||
OpenAI가 아닌 provider row는 현재 live OpenAI TTS로 보내지 않고 기존 fallback을 사용한다.
|
||||
dev 런타임 스키마 보강은 기존 DB의 `app.persona_voice_map` 누락도 복구해 seed materializer와
|
||||
`/voice/ws` 바인딩이 같은 테이블을 사용하게 한다.
|
||||
- 로컬 개발에서 `VIGNETTE_VOICE_TTS_PROVIDER=higgs`이면 P1만 loopback Higgs 서버로 합성한다.
|
||||
`scripts/higgs-tts-server.py`는 설치된 `higgs-audio-v3-tts-4b`를 다운로드 없이 한 번만 GPU에 올리고,
|
||||
저장소의 무참조 synthetic seed만 reference로 쓴다. `/health`는 model/load time/reference policy를,
|
||||
`/tts`는 24kHz WAV와 provider/model 헤더를 반환한다. 이 경로는 dev 전용이며 non-dev 설정은 fail-closed다.
|
||||
프론트는 마이크 클릭과 텍스트 발화 전송 시 `AudioContext`를 먼저 resume해 재생 권한을 확보하고,
|
||||
TTS Blob을 Web Audio buffer source로 재생하면서 `AnalyserNode`로 립싱크 RMS를 산출한다. 디코딩 실패 시
|
||||
`<audio>` 재생으로 fallback한다.
|
||||
- 텍스트 턴의 AI 내담자 응답은 인증된 `POST /voice/speech`가 `session_id`/`turn_seq`로 소유 회기를 다시
|
||||
로드하고, 이미 저장된 client-visible 내담자 응답만 MP3로 합성한다. 브라우저가 임의 문장을 보내는 유료
|
||||
TTS 프록시가 아니며, 마이크 WebSocket과 같은 voice map/OpenAI TTS 어댑터를 공유한다.
|
||||
로드하고, 이미 저장된 client-visible 내담자 응답만 선택 provider의 오디오로 합성한다. 브라우저가 임의
|
||||
문장을 보내는 TTS 프록시가 아니며, 마이크 WebSocket과 같은 voice map/TTS 어댑터를 공유한다.
|
||||
- 키 없으면 명확히 degraded(`is_available()=False`, `VoiceUnavailable`). 라우트가 503/WS close로 변환.
|
||||
|
||||
`/voice/ws` WebSocket 캐스케이드(`routes/voice.py`):
|
||||
|
|
@ -362,7 +369,8 @@ session lifecycle을 유지하며, future Node read API는 이 read-model contra
|
|||
학습자 발화 turn(speaker=`counselor`, 원문 `text` + `text_masked`) + 내담자 응답 turn(speaker=`client`)
|
||||
순차 append → 상태 갱신. `EngineError`는 503으로 변환.
|
||||
- `POST /sessions/{id}/stream` — SSE 경로(`run_turn_stream`). token/done/ping/error를 흘리고,
|
||||
done 시점에 학습자 발화 + 누적 내담자 응답을 영속화. `sse_heartbeat_seconds`마다 ping(Cloudflare 타임아웃 회피).
|
||||
done 시점에 학습자 발화 + 누적 내담자 응답 + 상태를 먼저 영속화하고 즉시 완료한다. fast-loop 평가는
|
||||
응답 경로 밖에서 같은 learner turn에 사후 저장한다. `sse_heartbeat_seconds`마다 ping(Cloudflare 타임아웃 회피).
|
||||
- `POST /sessions/{id}/live-coach` — 방금 완료된 상담자 발화를 워크북 요약/RAG/evaluator fast-loop 신호와 대조해
|
||||
라이브 코칭 카드 1개를 반환하고 `app.live_coach_events`에 `use/-1`로 저장한다. 저장 payload는 마스킹
|
||||
excerpt와 코칭 구조화 JSON이며, 잔여 기회가 없으면 409로 차단한다.
|
||||
|
|
@ -550,9 +558,9 @@ HTTP error mapping을 유지한다.
|
|||
|
||||
## 3. 엔진 게이트웨이 — `apps/api/engine_gateway/gateway.py`
|
||||
|
||||
컨테이너 밖(호스트)에서 도는 **별도 서비스**. `claude -p`(Opus 4.8) 상주 멀티턴 풀을 흡수한다.
|
||||
회기당 1 `EngineSession` = `claude -p` 프로세스 1개 상주 → 페르소나 system 프롬프트 고정 + 발화마다
|
||||
stdin 주입(턴 간 컨텍스트 유지 + prompt caching 재사용).
|
||||
컨테이너 밖(호스트)에서 도는 **별도 서비스**. Claude CLI 상주 멀티턴 풀과 Anthropic API,
|
||||
Codex CLI, Agy CLI의 모델 탐색·실행 차이를 흡수한다. Claude CLI는 회기당 1 `EngineSession` 프로세스를
|
||||
상주시켜 페르소나 system 프롬프트와 prompt caching을 유지한다. 나머지 공급자는 격리된 stateless 호출로 실행한다.
|
||||
|
||||
실행:
|
||||
```
|
||||
|
|
@ -570,11 +578,20 @@ cd apps/api && uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099
|
|||
- `POST /v1/stream` — `turn_stream()`이 assistant 텍스트 델타를 즉시 yield → SSE
|
||||
`event: token` / `event: done`(provider/model/cost 메타) / `event: error` 프레이밍.
|
||||
- `session_id`가 살아있으면 풀 재사용, 아니면 1회성(ephemeral) 세션 생성 후 close(`_resolve_session`).
|
||||
- **공급자 capability** `GET /v1/capabilities` — Codex app-server `model/list`, `agy models`, Anthropic
|
||||
`GET /v1/models` 또는 Claude CLI 공식 alias를 공통 `EngineModelOption`으로 정규화한다. 60초 캐시와
|
||||
관리자 강제 새로고침을 지원한다.
|
||||
- `GET /ready` — `/health`(얕은 프로세스 liveness)와 달리 실제 1턴 생성을 시도해
|
||||
"설치됐지만 미인증" CLI 상태를 학습자 도달 전에 잡는다(TTL 캐시).
|
||||
선택한 provider/model/reasoning_effort의 "설치됐지만 미인증" 상태를 학습자 도달 전에 잡는다(TTL 캐시).
|
||||
|
||||
provider 라우팅 모드는 `ENGINE_MODE`(`app/config.py`)로 선택: `claude_api`(기본, Anthropic Messages API
|
||||
직결) / `claude_cli`(로컬 상주 풀) / `openai`(폴백/평가 보조) / `solar`(국내, PII 민감구간 inference_geo:kr).
|
||||
provider 라우팅 모드는 `ENGINE_MODE`(`app/config.py`) 또는 DB의 관리자 설정으로 선택한다.
|
||||
실행 가능 공급자는 `claude_cli`, `claude_api`, `codex_cli`, `agy_cli`다. Codex 기본은
|
||||
`gpt-5.6-terra` / Medium, Agy 기본은 `gemini-3.6-flash-high` / High다. `openai`와 `solar`는
|
||||
기존 계약값을 보존하지만 실행 어댑터가 없어 capability에서 unavailable로 fail-closed한다.
|
||||
|
||||
`AdminEngineConfigResponse`는 provider·URL·model에 `reasoning_effort`를 더해 DB에 저장한다. PATCH는
|
||||
제안된 게이트웨이에서 capability를 강제 재조회한 뒤 실제 목록에 없는 모델·추론 강도, 미인증 공급자,
|
||||
접속 불가 URL을 422로 거부하고 저장 성공 후 `EngineClient` 런타임 설정을 함께 교체한다.
|
||||
|
||||
> 포트 주의: 게이트웨이 docstring 예시는 `:9099`이고, `app/config.py`의 bare Settings fallback은
|
||||
> legacy compose 서비스명 기반 `http://engine:8100`이다. 현재 지원 compose/local runtime은
|
||||
|
|
@ -602,7 +619,7 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom).
|
|||
| `/teach/personas` | PersonaStudio(페르소나 저작·검수) | teacher/admin |
|
||||
| `/teach/session/:sessionId/review` | SessionReview(교수자 읽기 전용 회기 검토) | teacher/admin |
|
||||
| `/admin` | Admin(운영 홈) | admin |
|
||||
| `/admin/ai` | AdminAi(AI 엔진 설정, 기간별 DB 비용·토큰·계량 커버리지, provider/model 원장, 평가 캐시 효율) | admin |
|
||||
| `/admin/ai` | AdminAi(AI 공급자·실시간 모델·추론 강도 드롭다운, 기간별 DB 비용·토큰·계량 커버리지, provider/model 원장, 평가 캐시 효율) | admin |
|
||||
| `/admin/users` | Admin(사용자 관리) | admin |
|
||||
| `/admin/access` | Admin(접근 권한) | admin |
|
||||
| `/admin/tickets` | Admin(운영 티켓 처리 큐: 접수, 필터, 카테고리 큐, 우선순위, 수동 상태 변경 감사) | admin |
|
||||
|
|
@ -612,6 +629,13 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom).
|
|||
- `App.tsx`는 모든 역할 페이지를 `lazy()`로 로드하고 공통 `Suspense` 부트 경계를 사용한다. 페이지별 순수 표시 계산은
|
||||
`pages/*/model.ts`, 브라우저 음성 캡처는 `pages/session/voiceCapture.ts`, 페르소나 이름·난도·아바타 팔레트는
|
||||
`lib/personaViewModel.ts`가 소유해 라우트 컴포넌트의 API/상태/렌더 책임과 분리한다.
|
||||
- Pages 배포 전환 중 열린 탭이 삭제된 lazy 청크를 요청하면 `lib/chunkRecovery.ts`가 Vite
|
||||
`vite:preloadError`를 받아 현재 경로에서 문서 재로드를 1회만 수행한다. 정상 라우트 렌더 뒤 재시도 표식을
|
||||
지우고, 같은 경로의 연속 실패는 무한 재로드하지 않고 `RouteErrorBoundary`의 수동 복구 액션으로 넘긴다.
|
||||
- 최초 `/auth/me` 복원은 요청별 5초 상한과 짧은 3회 재시도를 둔다. 재부팅 직후 API가 늦게 올라와도
|
||||
세션을 즉시 로그아웃 처리하지 않으며, 끝내 연결되지 않으면 무한 `불러오는 중…` 대신 같은 HttpOnly
|
||||
세션으로 다시 확인할 수 있는 `AuthRestoreGate` 복구 화면을 표시한다. `401`만 정상 로그아웃 상태로
|
||||
확정하고 네트워크/5xx 실패는 권한 상실과 구분한다.
|
||||
- API 클라이언트 `src/lib/api.ts`:
|
||||
- `apiFetch`/`api`(get/post/put/del) — 표준 JSON, 에러는 `ApiError`로 정규화, 항상 `credentials:"include"`.
|
||||
- SSE: `openSessionStream(sessionId, text, handlers)` — `fetch` 스트림을 직접 라인 파싱해
|
||||
|
|
@ -769,18 +793,42 @@ DB 레벨 이중강제(`04_audit_eval_rls.sql` §5, `app/db.py` `acquire()`):
|
|||
- 프론트의 최초 진입 경로(`initialPathForUser`)는 pending이면 `/pending`, 온보딩 미완료 일반 사용자는
|
||||
`/onboarding`, 관리자 콘솔 접근권이 있는 사용자는 기본 역할이 learner/teacher여도 `/admin`을 우선한다.
|
||||
역할 전환용 `roleHomePath`는 그대로 역할별 홈(`/learn`, `/teach`, `/admin`)만 소유한다.
|
||||
- SPA 라우트 전환은 `App.tsx`가 `pathname` 변경마다 문서 스크롤을 맨 위로 복원한다. 또한 브라우저
|
||||
`history.scrollRestoration`을 `manual`로 두고 `pageshow`에서도 다시 복원해, 재부팅 뒤 기존 관리자 탭을
|
||||
되살릴 때 이전 `scrollY` 때문에 sticky 셸만 보이고 본문이 화면 위로 밀리는 빈 화면을 막는다.
|
||||
- SPA 라우트 전환은 `App.tsx`가 `pathname` 변경마다 문서 스크롤을 맨 위로 복원하고 브라우저
|
||||
`history.scrollRestoration`을 `manual`로 둔다. 실제 세로 스크롤 소유자는 document가 아니라
|
||||
`.vg-main`이므로 `AppShell`이 mount·pathname 변경·`pageshow`마다 내부 `scrollTop/scrollLeft`를
|
||||
직접 0으로 되돌린다. 재부팅 뒤 기존 관리자 탭이나 긴 관리자 하위 페이지에서 이동한 화면에 sticky 셸만
|
||||
남고 본문이 화면 밖에 머무르는 빈 화면을 막는다.
|
||||
- 관리자 콘솔은 shell만 남고 본문이 비는 silent failure를 허용하지 않는다. `/admin/users` 응답의
|
||||
`cohort_ids`, `active_sessions`, `created_at`와 `/admin/tickets` 응답의 `summary`, `by_status`,
|
||||
`by_priority` 같은 필드가 런타임 계약과 다르면 프론트가 안전한 기본값으로 정규화하고 `관리자 데이터 진단`
|
||||
패널에 깨진 필드와 asset/path를 표시한다. 앱 route-level error boundary는 관리자 컴포넌트 바깥에서
|
||||
터진 렌더 예외도 full white page 대신 진단 화면으로 노출하고, 본문 0-height 상태도 route-scoped
|
||||
diagnostic panel로 표시해 운영자가 원인 없이 빈칸만 보지 않게 한다.
|
||||
- 관리자 DOM/CSS는 광고 차단기의 generic cosmetic selector와 충돌하는 `ad-*` 접두사를 쓰지 않고
|
||||
`vgops-*` namespace를 쓴다. EasyList에는 `.ad-root`와 `.ad-section`이 실제 광고 숨김 규칙으로
|
||||
등록되어 있어, 옛 접두사는 API가 모두 정상이어도 관리자 본문 전체를 `display:none`으로 만들었다.
|
||||
관리자 루트와 진단 marker는 class명이 아닌 `data-vignette-admin-*` 속성으로 식별한다.
|
||||
- 이 계약은 관례가 아니라 빌드 게이트다. `scripts/check-cosmetic-filter-safety.mjs`가 프로덕션 TS/TSX/CSS와
|
||||
`index.html`의 class/id 후보를 정적으로 검사하고, 광고 의미의 위험 namespace를 발견하면 `build`와
|
||||
`lint`를 실패시킨다. 검사기는 내장된 위험/안전 fixture를 매번 먼저 실행해 탐지 로직이 무력화된 채
|
||||
통과하지 못하게 한다. 공개 관리자 E2E는 동일 EasyList 규칙 아래 5경로의 실제 픽셀 가시성을 별도로
|
||||
검증하므로 정적 검사와 런타임 검사가 서로 다른 실패면을 막는다.
|
||||
- `index.html`은 React module 실행 자체가 실패하는 경우도 full white page로 두지 않는다. 부트스트랩 watchdog은
|
||||
3.5초/8초 시점에 body/root visible content와 관리자 본문 marker를 검사하고, 실패 시 `Vignette 화면 진단`
|
||||
패널을 React root 바깥 body에 직접 추가해 path, asset, bodyText, visibleNodes, global error를 표시한다.
|
||||
패널을 React root 바깥 body에 직접 추가해 path, asset, bodyText, visibleNodes, mainScrollTop, global error를
|
||||
표시한다. visible node와 관리자 marker는 `.vg-main` viewport 교차, rect, computed display/visibility/opacity를
|
||||
함께 검사하므로 화면 밖 DOM이나 광고 차단기에 숨은 DOM을 정상 픽셀로 오인하지 않는다.
|
||||
- 공개 런타임은 관리자·인증 제어면(`environment=prod`, `db=true`)과 AI 엔진 readiness를 별도 게이트로
|
||||
감시한다. 엔진만 실패하면 `start-public-runtime.ps1`가 살아 있는 API·웹·cloudflared를 유지하고 엔진만
|
||||
복구한다. 부팅 작업도 엔진이 늦더라도 관리자·인증 API가 준비되면 성공으로 끝내며 degraded 엔진은 별도
|
||||
경고로 남긴다. 따라서 엔진 probe 실패가 API 재시작과 관리자 세션 복원 공백으로 전파되지 않는다.
|
||||
- 설정 기반 슈퍼 관리자/관리자 이메일이 로그인하거나 기존 세션을 복원하면 유효한 관리자 접근권을
|
||||
`app.app_user.admin_access`에도 영속화한다. primary role은 learner/teacher로 유지할 수 있지만,
|
||||
재기동 뒤 환경설정 판정만으로 권한을 복원하지 않으며 기본 역할 사이드바에도 `운영 콘솔` 링크를 노출한다.
|
||||
관리자 작업면에서는 `navRole="admin"`으로 전체 관리자 5경로를 항상 표시한다.
|
||||
- OAuth 로그인에서 이메일 기반 기존 사용자와 provider external id를 연결할 때 nullable
|
||||
`admin_access` SQL 인자는 명시적으로 `boolean` 캐스팅한다. non-dev의 managed-user 저장 실패는
|
||||
메모리 폴백으로 숨기지 않고 원래 DB 예외를 서버 로그에 남긴 뒤 503으로 닫는다.
|
||||
- OAuth/SAML callback은 저장된 `next`가 일반 진입 경로(`/`, `/learn`, `/teach`, `/login`, `/onboarding`)이고
|
||||
로그인 사용자가 관리자 콘솔 접근권을 가지면 `/admin`으로 정규화한다. 단, `/learn/session/...` 같은 깊은
|
||||
링크는 사용자가 의도적으로 연 URL일 수 있으므로 보존한다.
|
||||
|
|
@ -845,13 +893,15 @@ POST /auth/dev-login {email, role: learner|teacher|admin, display_name}
|
|||
→ __Host-vignette_sid 쿠키 발급(웹은 Login.tsx에 dev-login 경로 존재)
|
||||
```
|
||||
|
||||
### 7.2 엔진 게이트웨이 (AI 턴 생성, ENGINE_MODE=claude_cli)
|
||||
### 7.2 엔진 게이트웨이 (AI 턴 생성, 다중 공급자)
|
||||
|
||||
```powershell
|
||||
cd D:\workspace\vignette\apps\api
|
||||
python -m uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099
|
||||
# API의 ENGINE_URL을 게이트웨이 포트로 맞춘다(예: http://127.0.0.1:9099)
|
||||
```
|
||||
`claude_api`는 게이트웨이 프로세스에 `ANTHROPIC_API_KEY`, CLI 모드는 해당 호스트에 로그인된
|
||||
`claude`/`codex`/`agy` 실행 파일이 필요하다. 관리자 화면의 목록 조회가 unavailable이면 저장도 차단된다.
|
||||
게이트웨이가 없으면 `/health`의 `engine:false`이고 턴 생성은 실패하지만, UI/네비/로그인/페르소나/
|
||||
세션생성(in-memory)은 동작한다.
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ DB가 없으면 인메모리 degraded 폴백으로 기동한다.
|
|||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1
|
||||
# 로컬 Higgs Audio v3 P1 음성까지 함께 연결
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1 -UseHiggsVoice
|
||||
# 종료: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-down.ps1
|
||||
# DB 컨테이너까지 멈출 때만: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-down.ps1 -Db
|
||||
```
|
||||
|
|
@ -33,7 +35,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1
|
|||
- 진입점 **http://localhost:5173** → 로그인 페이지에서 **dev-login**(아무 `@hs.ac.kr`, role learner/teacher/admin).
|
||||
- Docker가 있으면 기본적으로 `127.0.0.1:55432` DB 컨테이너를 사용한다. 새 컨테이너 생성 시 `POSTGRES_USER=vignette_owner`, API용 앱 role은 `DATABASE_URL` 사용자로 분리해 RLS 검증 기반을 보존한다. Docker가 없거나 `-NoDb`를 쓰면 in-memory degraded로 뜬다.
|
||||
- 로그는 `.devlogs/`(gitignore). 코드 수정 후에는 dev-up을 다시 실행해 재기동(reload 미사용).
|
||||
- 옵션: `-NoGateway`(기존 gateway 보존), `-NoWeb`(기존 web 보존, API만 재기동), `-NoDb`(DB 컨테이너 보장 건너뜀).
|
||||
- 옵션: `-NoGateway`(기존 gateway 보존), `-NoWeb`(기존 web 보존, API만 재기동), `-NoDb`(DB 컨테이너 보장 건너뜀),
|
||||
`-UseHiggsVoice`(설치된 `higgs-audio-v3-tts-4b`를 127.0.0.1:9881에 상주시켜 P1 TTS로 연결).
|
||||
- `-NoGateway`/`-NoWeb`를 쓰면 해당 컴포넌트의 stale 정리도 건너뛰고, API 정리는 지정한 `-ApiPort` listener만 대상으로 한다. public `8001`, Tailnet `8010`, local `8000`을 나눠 띄운 상태에서 API-only 재기동할 때 다른 포트를 건드리지 않는다.
|
||||
- `dev-down.ps1`은 기본적으로 DB 컨테이너를 보존한다. 컨테이너도 멈추려면 `-Db`를 명시한다.
|
||||
|
||||
|
|
@ -90,7 +93,10 @@ DB·엔진 없이도 UI/로그인/페르소나/세션 생성까지는 동작한
|
|||
- **Python 3.11** (운영 스크립트가 Python 3.11 기준). 가상환경 권장.
|
||||
- **Node.js 22 + npm** — CI와 같은 기준. newer LTS는 별도 재검증 전까지 기준선으로 쓰지 않는다.
|
||||
- (선택) **Docker Desktop** — `infra/docker-compose.yml` 전체 스택을 띄울 때만.
|
||||
- (선택) **`claude` CLI** — `ENGINE_MODE=claude_cli`로 실제 턴 생성을 할 때. 설치 후 로그인되어 있어야 한다.
|
||||
- (선택) **`claude` CLI** — `claude_cli` 실행. 설치 후 로그인되어 있어야 한다.
|
||||
- (선택) **Codex CLI** — `codex_cli` 실행과 `model/list` 탐색. 기본 선택은 `gpt-5.6-terra` / Medium.
|
||||
- (선택) **Agy CLI** — `agy_cli` 실행과 `agy models` 탐색. 기본 선택은 `gemini-3.6-flash-high` / High.
|
||||
- (선택) **`ANTHROPIC_API_KEY`** — `claude_api`의 모델 목록과 Messages API 실행.
|
||||
|
||||
### Python 의존성 설치 (`apps/api`)
|
||||
|
||||
|
|
@ -130,7 +136,8 @@ npm install
|
|||
| `ENVIRONMENT` | `dev` | dev여야 DB 폴백·dev-login·seed 폴백이 허용됨 |
|
||||
| `DATABASE_URL` | `postgresql://...@127.0.0.1:55432/vignette` | 미연결 시 degraded 폴백(dev 한정) |
|
||||
| `ENGINE_URL` | `http://127.0.0.1:9099` | 엔진 게이트웨이 베이스 URL |
|
||||
| `ENGINE_MODE` | `claude_cli` | 엔진 provider 라우팅 |
|
||||
| `ENGINE_MODE` | `claude_cli` | `claude_cli` / `claude_api` / `codex_cli` / `agy_cli` 공급자 라우팅. 실제 운영 변경은 관리자 드롭다운이 DB에 저장 |
|
||||
| `VIGNETTE_LIVE_CLIENT_PROVIDER` | `claude_cli` | 실시간 내담자 AI 전용 lane. 관리자에서 선택한 evaluator/review 공급자와 분리해 회기별 Claude 상주 세션을 재사용 |
|
||||
| `AUTH_DEV_LOGIN_ENABLED` | `true` | dev-login 엔드포인트 활성화 |
|
||||
| `AUTH_ALLOWED_EMAIL_DOMAINS` | `["hs.ac.kr","twentyoz.kr"]` | 기본 로그인 허용 이메일 도메인(dev-login 포함 검증). `/admin/users`에 미리 등록된 정확한 이메일은 도메인 밖이어도 예외로 로그인 가능 |
|
||||
| `AUTH_SUPER_ADMIN_EMAILS` | `["yunchan@twentyoz.kr","hoonjungkoo@hs.ac.kr"]` | 학습자·교수자·관리자 공간 접근과 승인 상태를 부여할 슈퍼 관리자 이메일 |
|
||||
|
|
@ -139,6 +146,8 @@ npm install
|
|||
| `AUTH_EMAIL_COHORT_MAP` | `{}` | 특정 이메일을 cohort id로 매핑한다. 값은 comma-separated 문자열도 허용 |
|
||||
| `AUTH_DOMAIN_COHORT_MAP` | `{}` | 이메일/Google hosted domain을 cohort id로 매핑한다. Google/SAML/dev-login 세션 `cohort_ids`에 반영 |
|
||||
| `VIGNETTE_VOICE_POC_SAMPLE_TTS` | `false` | P1 무참조 샘플 음성을 `/voice/ws` TTS에 연결하는 개발 전용 플래그. 마이크/STT는 `OPENAI_API_KEY` 필요, 프로덕션 금지 |
|
||||
| `VIGNETTE_VOICE_TTS_PROVIDER` | `openai` 또는 `higgs` | TTS 공급자. `higgs`는 dev + P1에서만 허용하며 다른 환경은 설정 검증에서 차단 |
|
||||
| `VIGNETTE_HIGGS_TTS_URL` | `http://127.0.0.1:9881` | 로컬 Higgs 상주 서버. 저장소의 무참조 synthetic seed만 화자 참조로 사용 |
|
||||
| `EVALUATOR_SEMANTIC_CACHE_ENABLED` | `true` | fast/deep evaluator structured 결과 인메모리 캐시 활성화. 원문 prompt/completion은 저장하지 않음 |
|
||||
| `EVALUATOR_SEMANTIC_CACHE_TTL_SECONDS` | `900` | evaluator cache TTL(초). 0 이하면 비활성 |
|
||||
| `EVALUATOR_SEMANTIC_CACHE_MAX_ENTRIES` | `256` | evaluator cache LRU 최대 엔트리 수. 0 이하면 비활성 |
|
||||
|
|
@ -407,9 +416,15 @@ npm run e2e # Playwright (web + api + DB 스택 필요)
|
|||
|
||||
## 5. 엔진 게이트웨이 (AI 턴 생성)
|
||||
|
||||
`ENGINE_MODE=claude_cli`에서는 실제 발화 생성을 `engine_gateway`가 담당한다.
|
||||
이 게이트웨이는 로컬 `claude -p`(Opus 4.8) 상주 프로세스 풀로, **컨테이너 밖(호스트)에서** 9099 포트로 실행한다.
|
||||
회기 1개 = `claude -p` 프로세스 1개로 컨텍스트·프롬프트 캐시를 재사용한다.
|
||||
실제 발화 생성은 `ENGINE_MODE`와 무관하게 `engine_gateway`가 담당한다. 게이트웨이는
|
||||
**컨테이너 밖(호스트)** 9099 포트에서 Claude CLI 상주 풀, Anthropic Messages API, Codex CLI,
|
||||
Agy CLI를 공통 계약으로 라우팅한다. 관리자는 `/admin/ai` 또는 `/settings`의 AI 운영 섹션에서 공급자를
|
||||
고르고, 게이트웨이가 반환한 모델·추론 강도만 드롭다운으로 저장할 수 있다.
|
||||
|
||||
실시간 내담자 발화는 `VIGNETTE_LIVE_CLIENT_PROVIDER=claude_cli`일 때 관리자 기본 공급자와 별도의
|
||||
회기별 상주 Claude lane을 쓴다. Agy를 evaluator/review 기본값으로 유지해도 내담자 답변이 Agy CLI의
|
||||
매 호출 도구 스키마 부팅 비용을 기다리지 않는다. 스트림의 learner/client turn과 상태는 먼저 저장하고
|
||||
`done`을 보낸 뒤 fast-loop 평가는 백그라운드에서 같은 learner turn에 durable하게 붙인다.
|
||||
|
||||
### 5.1 실행
|
||||
|
||||
|
|
@ -420,24 +435,29 @@ python -m uvicorn engine_gateway.gateway:app --host 127.0.0.1 --port 9099
|
|||
|
||||
관련 환경변수(`gateway.py`):
|
||||
- `CLAUDE_BIN` — claude 실행 파일 경로(기본 `claude`). PATH에 없으면 절대경로 지정.
|
||||
- `CODEX_BIN`, `AGY_BIN` — 각 CLI 경로. Windows Codex는 npm shim 아래 native exe를 자동 탐색한다.
|
||||
- `ANTHROPIC_API_KEY`, `ANTHROPIC_API_BASE` — Anthropic 모델 목록·Messages API.
|
||||
- `ENGINE_CLI_CWD` — Codex/Agy를 저장소 밖에서 실행할 격리 cwd.
|
||||
- `ENGINE_CAPABILITY_CACHE_TTL_SECONDS`(기본 60), `ENGINE_CLI_TIMEOUT_SECONDS`(기본 300).
|
||||
- `ENGINE_MODEL` — 비우면 CLI 기본(Opus 4.8), `ENGINE_FALLBACK_MODEL`, `SESSION_BUDGET_USD` 등.
|
||||
|
||||
### 5.2 헬스/레디 확인
|
||||
|
||||
게이트웨이는 두 가지 점검 엔드포인트를 제공한다.
|
||||
게이트웨이는 세 가지 점검 엔드포인트를 제공한다.
|
||||
|
||||
- `GET /health` — 얕은 프로세스 liveness. `{"ok": true, "engine": "claude_p", ...}`
|
||||
- `GET /ready` — **실제로 1회 생성**을 돌려 `claude -p` 인증/동작까지 증명(성공 200, 실패 503).
|
||||
설치는 됐지만 로그인 안 된 상태를 여기서 잡는다.
|
||||
- `GET /health` — 얕은 게이트웨이 프로세스 liveness. `{"ok": true, "engine": "claude_cli", ...}`
|
||||
- `GET /ready?provider=&model=&reasoning_effort=` — 선택 조합으로 **실제 1회 생성**을 돌려 인증·동작까지 증명.
|
||||
- `GET /v1/capabilities?provider=&force=` — 현재 계정에서 선택 가능한 모델·추론 강도를 반환.
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod http://127.0.0.1:9099/health
|
||||
Invoke-RestMethod http://127.0.0.1:9099/ready
|
||||
Invoke-RestMethod 'http://127.0.0.1:9099/v1/capabilities?provider=codex_cli&force=true'
|
||||
Invoke-RestMethod 'http://127.0.0.1:9099/ready?provider=codex_cli&model=gpt-5.6-terra&reasoning_effort=medium'
|
||||
```
|
||||
|
||||
API의 `/health`는 게이트웨이의 `/ready`를 호출(404면 `/health`로 폴백)해 `engine` 필드를 채운다
|
||||
(`engine_client.health_detail`). 즉 게이트웨이가 떠 있어도 `claude`가 인증 안 됐으면 `/ready`가 503이라
|
||||
API `/health`의 `engine: false`가 된다.
|
||||
API의 `/health`는 현재 DB 설정의 provider/model/reasoning_effort를 게이트웨이 `/ready`에 전달하고
|
||||
(구형 게이트웨이는 `/health`로 폴백) `engine` 필드를 채운다. 따라서 프로세스만 떠 있고 선택한 CLI/API가
|
||||
미인증이거나 모델 조합을 실행할 수 없으면 API `/health`의 `engine: false`가 된다.
|
||||
|
||||
### 5.3 프로빙 스크립트 (선택)
|
||||
|
||||
|
|
@ -488,8 +508,8 @@ RAG 임베딩/리랭커 의존성은 기본 이미지에 설치하지 않는다.
|
|||
```powershell
|
||||
# 백엔드 (apps/api)
|
||||
cd apps\api
|
||||
python -m pytest app/ -q # 백엔드 기준선 400 pass
|
||||
python -m pytest engine_gateway\ -q # 현재 29 pass
|
||||
python -m pytest app/ -q # 백엔드 기준선 432 pass
|
||||
python -m pytest engine_gateway\ -q # 현재 44 pass
|
||||
|
||||
# 웹 (apps/web)
|
||||
cd apps\web
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ Vignette 저장소의 모든 검증 수단(백엔드 단위 테스트, 웹 타
|
|||
|
||||
| 검증 | 작업 디렉터리 | 명령 | DB | API(8000) | 웹(5173) | 엔진GW(9099) | 브라우저 | 현재 통과 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 백엔드 단위 테스트 | `apps/api` | `python -m pytest app/ -q` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | 2026-07-15 전체 실행 400 passed |
|
||||
| 엔진 게이트웨이 테스트 | `apps/api` | `python -m pytest engine_gateway/ -q` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | 현재 29 pass |
|
||||
| 백엔드 단위 테스트 | `apps/api` | `python -m pytest app/ -q` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | 2026-07-30 전체 실행 432 passed |
|
||||
| 엔진 게이트웨이 테스트 | `apps/api` | `python -m pytest engine_gateway/ -q` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | 2026-07-30 전체 실행 44 passed |
|
||||
| API 타입 생성 체크 | `apps/web` | `npm run check:api-types` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
||||
| 웹 타입체크 | `apps/web` | `npm run typecheck` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
||||
| 웹 빌드 | `apps/web` | `npm run build` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
||||
|
|
@ -42,15 +42,15 @@ Vignette 저장소의 모든 검증 수단(백엔드 단위 테스트, 웹 타
|
|||
|
||||
```sh
|
||||
# apps/api
|
||||
python -m pytest app/ -q # 앱 단위 테스트: 2026-07-15 전체 실행 400 passed
|
||||
python -m pytest engine_gateway/ -q # 게이트웨이 단위 테스트 현재 29 pass
|
||||
python -m pytest app/ -q # 앱 단위 테스트: 2026-07-30 전체 실행 432 passed
|
||||
python -m pytest engine_gateway/ -q # 게이트웨이 단위 테스트: 2026-07-30 전체 실행 44 passed
|
||||
```
|
||||
|
||||
수집만 빠르게 확인하려면:
|
||||
|
||||
```sh
|
||||
python -m pytest app/ --collect-only -q # 현재: "400 tests collected" (2026-07-15)
|
||||
python -m pytest engine_gateway/ --collect-only -q # 현재: "29 tests collected"
|
||||
python -m pytest app/ --collect-only -q # 현재: "432 tests collected" (2026-07-30)
|
||||
python -m pytest engine_gateway/ --collect-only -q # 현재: "44 tests collected" (2026-07-30)
|
||||
```
|
||||
|
||||
> 참고: 실행 중 `PendingDeprecationWarning: Please use 'import python_multipart'`
|
||||
|
|
@ -80,6 +80,7 @@ python -m pytest engine_gateway/ --collect-only -q # 현재: "29 tests collected
|
|||
| 파일 | 검증 영역 |
|
||||
|---|---|
|
||||
| `engine_gateway/test_gateway_model.py` | 게이트웨이 모델 선택·공유 engine contract·SSE 요청/응답 계약(ENGINE_MODE별) |
|
||||
| `engine_gateway/test_provider_registry.py` | Claude/Anthropic/Codex/Agy capability 정규화, 기본값, CLI 인자, 잘못된 모델·추론 강도 차단 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -131,7 +132,7 @@ python scripts/smoke-resistance-openness-db.py \
|
|||
npm install # 최초 1회 (devDependencies: typescript, vite, @playwright/test 등)
|
||||
npm run check:api-types # FastAPI OpenAPI ↔ src/lib/api.gen.ts 동기화 확인
|
||||
npm run typecheck # tsc -b — 타입 오류 0 확인
|
||||
npm run build # tsc -b && vite build — 프로덕션 번들 생성까지 확인
|
||||
npm run build # cosmetic-filter namespace gate → tsc -b → vite build
|
||||
```
|
||||
|
||||
API DTO를 바꿨다면 먼저 생성 산출물을 갱신한다.
|
||||
|
|
@ -279,6 +280,14 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API
|
|||
`POST /voice/speech`, Session 마이크 UI를 함께 검증한다. 브라우저 `<audio>.play()`가 차단된 조건에서도
|
||||
Web Audio buffer source 재생이 시작되는지와 Session 마이크 UI가 `audio_end`에 browser voice
|
||||
activity/silence 메타를 싣는지 확인한다.
|
||||
- 2026-07-30 세션 음성/지연 focused 검증: 실제 로컬 P1 브라우저에서 응답 생성 중 textarea가 enabled이고
|
||||
다음 질문 초안이 보존되며, 음성 준비/재생 중에도 보내기가 enabled인 것을 확인했다. 이전 full API 2턴은
|
||||
fast-loop evaluator를 직렬로 기다려 완료가 24.17/24.57초였지만, 사후 평가 분리 뒤 실제 UI 응답·전송 해제는
|
||||
첫 턴 9.58초, 상주 세션 연속 턴 6.96초였다. `test_session_turn_persistence.py`는 느린 평가 훅이 끝나기 전에
|
||||
SSE `done`이 반환되고 이후 normalized 평가가 learner turn에 붙는 계약을 고정한다. Higgs API smoke는
|
||||
`higgs-audio-v3-tts-4b`, 24kHz mono WAV 362,924 bytes/7.56초, provider/model 헤더와 synthetic-seed-only
|
||||
reference policy를 확인했다. 검증: backend 432 passed, gateway 44 passed, typecheck/API types/build,
|
||||
session text stream 1 passed, voice skip/draft 1 passed.
|
||||
- 2026-07-13 focused 검증: `PLAYWRIGHT_PORT=5269 npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run --workers=1 --grep "drives one voice turn"` **1 passed**. 같은 로그인에서 텍스트 턴 저장→소유 회기/턴 기반 `/voice/speech`→OpenAI speech 요청→Web Audio buffer 재생 시작을 확인하고, 별도 새 회기에서 마이크 PCM/STT→AI reply→TTS chunk/`tts_end` 경로가 유지되는지 검증한다. 백엔드 voice focused는 **32 passed**이며, 운영 키 직접 smoke는 `gpt-4o-mini-tts`가 98,133-byte MP3(11.68초)를 반환했다.
|
||||
- 2026-07-01 focused 검증: `PLAYWRIGHT_PORT=5205 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1` **7 passed**. 실제 브라우저 `openSessionStream()` → `/sessions/{id}/stream` → DB-backed `/review` 축어록 저장 경로, AI 튜터 코칭 이력 저장/재로딩, WebSocket `stt_result` 음성 비언어 메타데이터, Session 마이크 UI가 생성한 voice activity/silence 메타, Phase 3 pre/post 점수의 DB-backed 저장/재조회, 그리고 세션 종료 background deep 평가가 durable DB row로 저장되어 교수자 리뷰가 `평가 완료`로 전환되는지 검증한다. AI 튜터 코칭 이력 검증은 `POST /live-coach` 응답과 DB-backed history payload의 `status=ready`, `latency_ms>0`도 확인해 규칙 기반 `degraded` fallback 200 응답이 정상 AI 코칭으로 통과하지 못하게 한다.
|
||||
- 2026-07-01 추가 DB-backed 검증: `PLAYWRIGHT_PORT=5238 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "finishes session end evaluation"` **1 passed**. 같은 자동 종료 평가 row가 `/teacher/dashboard`의 `recent_sessions`에서도 `evaluation_status=ready`, `review_ready=true`, `supervisor_state=평가 완료`로 반영되는지 실제 DB/API/엔진으로 검증한다.
|
||||
|
|
@ -334,12 +343,37 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API
|
|||
|
||||
`e2e/public-auth-turn.spec.ts`와 `e2e/public-admin-visual.spec.ts`는 공개 사이트
|
||||
(`https://vignette.chanpaca.net`)를 직접 타격하는 옵트인 스모크로, 로컬 웹 서버를 띄우지 않는다.
|
||||
관리자 시각 스모크는 운영 홈·사용자·권한·티켓의 본문 노출과 브라우저 `pageshow` 탭 복원 뒤
|
||||
스크롤/heading 가시성을 함께 검증한다. 절차는 `apps/web/e2e/README.md` 참고
|
||||
관리자 시각 스모크는 운영 홈·AI 운영·사용자·권한·티켓의 본문 노출, primary learner 화면의
|
||||
`운영 콘솔` 복귀, 브라우저 `pageshow` 탭 복원 뒤 실제 내부 `.vg-main.scrollTop=0`과 heading 가시성을
|
||||
함께 검증한다. document `window.scrollY`만 검사하면 관리자 셸의 실제 스크롤 잔류를 놓치므로 금지한다.
|
||||
로컬 `admin.spec.ts`의 `EasyList cosmetic filters` 회귀는 공식 목록에 있는 옛 `.ad-root`와
|
||||
`.ad-section` 숨김 규칙을 그대로 주입한 뒤에도 중립 `[data-vignette-admin-root]`와 운영 홈 heading이
|
||||
보이는지 확인한다. API 200이나 DOM 존재만으로 이 검증을 대신하면 광고 차단기 silent blank를 놓친다.
|
||||
`npm run check:cosmetic-filter-safety`는 `src/**/*.{ts,tsx,css}`와 `index.html`을 검사해
|
||||
`ad-*`, `ads-*`, `advert*`, `sponsor*` class/id namespace가 프로덕션 코드에 다시 들어오면 빌드를
|
||||
즉시 실패시킨다. 탐지기 자체의 양성·음성 fixture는 매 실행마다 먼저 검증하며,
|
||||
`npm run check:cosmetic-filter-safety:self-test`로 따로 실행할 수도 있다. 이 검사는 `npm run build`와
|
||||
`npm run lint` 앞단에 포함되므로 `scripts/start-public-runtime.ps1`의 실제 웹 빌드도 우회하지 못한다.
|
||||
공개 `public-admin-visual.spec.ts` 역시 같은 EasyList 규칙을 첫 paint부터 적용한 상태로 관리자 5경로의
|
||||
heading/root 실제 rect·computed visibility, legacy class 0, watchdog/console 오류 0을 확인한다.
|
||||
절차는 `apps/web/e2e/README.md` 참고
|
||||
(`E2E_PUBLIC_AUTH=1`, `npx playwright codegen ... --save-storage`로 인증 상태 캡처 후
|
||||
`E2E_PUBLIC_STORAGE_STATE` 재사용). 캡처한 storage state에는 API 세션 쿠키가 들어 있으니
|
||||
민감 정보로 취급한다.
|
||||
|
||||
### 3.8 배포 전환 청크 복구 스모크(선택)
|
||||
|
||||
`e2e/chunk-recovery-preview.spec.ts`는 프로덕션 빌드의 로그인 lazy 청크 첫 요청을 강제로 실패시켜
|
||||
문서가 정확히 한 번만 다시 로드되고 로그인 라우트가 정상 렌더되는지 검증한다. 로컬 `vite preview` 또는
|
||||
공개 도메인을 `PLAYWRIGHT_BASE_URL`로 지정하고 아래처럼 실행한다.
|
||||
|
||||
```powershell
|
||||
$env:E2E_PREVIEW_BUILD = "1"
|
||||
$env:PLAYWRIGHT_SKIP_WEB_SERVER = "1"
|
||||
$env:PLAYWRIGHT_BASE_URL = "http://127.0.0.1:5262" # 또는 https://vignette.chanpaca.net
|
||||
npx playwright test e2e/chunk-recovery-preview.spec.ts --project=chromium-single-run --workers=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 전체 검증 순서 권장안 (로컬)
|
||||
|
|
@ -356,9 +390,15 @@ cd apps/web && npm run typecheck && npm run build
|
|||
cd apps/web && npm run e2e
|
||||
```
|
||||
|
||||
엔진 턴 생성까지 보려면(음성/세션 MVP 등 일부 `@single-run`) 별도로 엔진 게이트웨이를
|
||||
포트 9099에 띄운다(`ENGINE_MODE=claude_cli`). 게이트웨이가 없으면 `/health`의 `engine:false`
|
||||
이고, 실제 턴 생성 시나리오는 실패한다. 레이아웃/시각 게이트는 게이트웨이 없이도 통과한다.
|
||||
엔진 턴 생성이나 관리자 모델 저장까지 보려면(음성/세션 MVP 등 일부 `@single-run`) 현재 코드의
|
||||
엔진 게이트웨이를 포트 9099에 띄운다. DB의 `admin_engine_config.engine_url`이 오래 떠 있던 구형
|
||||
게이트웨이를 가리키면 `/admin/engine-capabilities`가 404/503이므로 프로세스를 새 코드로 재기동해야 한다.
|
||||
게이트웨이가 없으면 `/health`의 `engine:false`이고 실제 턴 생성·모델 저장 시나리오는 실패한다.
|
||||
레이아웃/시각 게이트는 게이트웨이 없이도 통과한다.
|
||||
|
||||
공급자 실증은 capability만 확인하지 말고 최소 한 번 실제 고유 응답까지 확인한다. Codex는
|
||||
`gpt-5.6-terra` / Medium, Agy는 `gemini-3.6-flash-high` / High가 목록 기본값과 실행 결과 양쪽에서
|
||||
일치해야 한다. Anthropic은 실제 키가 없는 환경에서 unavailable·저장 거부가 정상이다.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue