현재 작업 전체 반영

This commit is contained in:
Yun Chan 2026-06-27 16:08:41 +09:00
parent 5560638e54
commit c0dddab594
85 changed files with 11322 additions and 539 deletions

View file

@ -3,11 +3,13 @@
Vignette 로컬 개발 스택(게이트웨이 9099 + API 8000 + 5173) 정리한다.
.DESCRIPTION
커맨드라인 기준으로 정확히 종료한다(고아 워커/리로더 포함). scripts/dev-up.ps1 .
기본은 Postgres 컨테이너를 보존한다. 함께 멈추려면 -Db를 명시한다.
.EXAMPLE
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-down.ps1
#>
param(
[int]$ApiPort = 8000
[int]$ApiPort = 8000,
[switch]$Db
)
$ErrorActionPreference = 'SilentlyContinue'
@ -28,5 +30,16 @@ Get-NetTCPConnection -LocalPort $ApiPort -State Listen -ErrorAction SilentlyCont
Select-Object -ExpandProperty OwningProcess -Unique |
Where-Object { $_ -and $_ -ne 0 } |
ForEach-Object { Stop-Process -Id $_ -Force }
if ($Db) {
if (Get-Command docker -ErrorAction SilentlyContinue) {
$running = docker ps --filter name=vignette-dev-db --format '{{.Names}}' 2>$null
if ($running -match 'vignette-dev-db') {
Write-Host ' stop db vignette-dev-db'
docker stop vignette-dev-db 1>$null
}
}
} else {
Write-Host ' keep db vignette-dev-db (필요하면 -Db로 중지)'
}
Start-Sleep -Seconds 1
Write-Host "완료."

View file

@ -14,6 +14,8 @@
엔진 게이트웨이를 띄우지 않는다(AI 생성 불가, UI만 테스트 ).
.PARAMETER NoWeb
vite 서버를 띄우지 않는다(API만 필요할 ).
.PARAMETER NoDb
Docker Postgres 보장을 건너뛰고 in-memory degraded 기동을 허용한다.
.EXAMPLE
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1
#>
@ -80,6 +82,78 @@ function Wait-Health([string]$url, [string]$label, [int]$timeoutSec = 40) {
Write-Host " WARN $label 미응답 ($url) — .devlogs 로그 확인"; return $false
}
function Read-EnvFileValue([string]$path, [string]$key) {
if (!(Test-Path -LiteralPath $path)) { return $null }
$pattern = ('^\s*{0}\s*=' -f [regex]::Escape($key))
$line = Get-Content -LiteralPath $path -Encoding utf8 |
Where-Object { $_ -match $pattern } |
Select-Object -First 1
if (-not $line) { return $null }
$value = ($line -replace $pattern, '').Trim()
$value = $value.Trim('"')
$value = $value.Trim("'")
return $value
}
function Warn-ComposeEnvReadiness {
$composeEnv = Join-Path $repo 'infra\.env'
if (!(Test-Path -LiteralPath $composeEnv)) {
Write-Host ' INFO infra\.env 없음 - docker compose 전체 스택은 .env 작성 후 사용.'
return
}
$required = @(
'POSTGRES_PASSWORD',
'APP_DB_PASSWORD',
'OPENAI_API_KEY',
'SESSION_SECRET',
'OAUTH_GOOGLE_CLIENT_ID',
'OAUTH_GOOGLE_CLIENT_SECRET'
)
$missing = @()
foreach ($key in $required) {
$value = Read-EnvFileValue $composeEnv $key
if ([string]::IsNullOrWhiteSpace($value)) { $missing += $key }
}
if ($missing.Count -gt 0) {
Write-Host (' WARN infra\.env 필수값 누락: {0} - compose api/proxy 기동 전 보강 필요.' -f ($missing -join ', '))
}
}
function Test-DevDbRoleSafety([string]$ownerUser, [string]$dbName) {
if ([string]::IsNullOrWhiteSpace($ownerUser) -or [string]::IsNullOrWhiteSpace($dbName)) { return }
$sql = "SELECT rolname || ':' || rolsuper || ':' || rolbypassrls FROM pg_roles WHERE rolname IN ('vignette_app','vignette_owner') ORDER BY rolname;"
$rows = & docker exec vignette-dev-db psql -U $ownerUser -d $dbName -At -c $sql 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Host ' WARN db role 점검 실패 - psql 접속/권한 확인 필요.'
return
}
foreach ($row in $rows) {
$parts = "$row".Split(':')
if ($parts.Count -lt 3) { continue }
if ($parts[0] -eq 'vignette_app' -and ($parts[1] -eq 't' -or $parts[2] -eq 't')) {
Write-Host ' WARN vignette_app role 이 SUPERUSER/BYPASSRLS 상태 - RLS 검증이 무의미함. 컨테이너를 owner/app 분리로 재생성 필요.'
}
}
}
function Inspect-DevDbContainer {
$healthMode = & docker inspect -f '{{if .Config.Healthcheck}}health{{else}}none{{end}}' vignette-dev-db 2>$null
if ($LASTEXITCODE -eq 0 -and "$healthMode" -eq 'none') {
Write-Host ' WARN vignette-dev-db healthcheck 없음 - 기존 컨테이너라면 재생성 시 healthcheck가 붙음.'
}
$ownerUser = & docker exec vignette-dev-db printenv POSTGRES_USER 2>$null
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($ownerUser)) { $ownerUser = 'vignette_owner' }
$dbName = & docker exec vignette-dev-db printenv POSTGRES_DB 2>$null
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($dbName)) { $dbName = 'vignette' }
& docker exec vignette-dev-db pg_isready -U $ownerUser 1>$null 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Host ' OK db (vignette-dev-db 준비됨)'
} else {
Write-Host ' WARN db 컨테이너는 있으나 pg_isready 실패'
}
Test-DevDbRoleSafety "$ownerUser" "$dbName"
}
function Ensure-DevDb {
if (!(Get-Command docker -ErrorAction SilentlyContinue)) {
Write-Host ' WARN docker CLI 없음 - DB 없이 degraded(UI 세션 시작 제한).'
@ -97,13 +171,13 @@ function Ensure-DevDb {
$running = docker ps --filter name=vignette-dev-db --format '{{.Names}}' 2>$null
if ($running -match 'vignette-dev-db') {
Write-Host ' OK db (vignette-dev-db 실행 중)'
Inspect-DevDbContainer
return
}
docker rm -f vignette-dev-db 1>$null 2>$null
$envLines = Get-Content -LiteralPath (Join-Path $api '.env')
$dbUrl = (($envLines | Where-Object { $_ -match '^DATABASE_URL=' }) -replace '^DATABASE_URL=', '').Trim()
$apiEnv = Join-Path $api '.env'
$dbUrl = Read-EnvFileValue $apiEnv 'DATABASE_URL'
if ($dbUrl -notmatch 'postgresql://([^:]+):([^@]+)@[^:]+:([0-9]+)/(\S+)') {
Write-Host ' WARN DATABASE_URL 파싱 실패 - DB 스킵'
return
@ -113,16 +187,27 @@ function Ensure-DevDb {
$p = $Matches[2]
$port = $Matches[3]
$db = $Matches[4]
$ownerUser = 'vignette_owner'
$ownerPassword = $p
$appUser = $u
$appPassword = $p
if ($appUser -eq $ownerUser) {
Write-Host ' WARN DATABASE_URL 이 owner role 을 사용 중 - RLS 검증을 위해 앱 전용 role URL 권장.'
}
$initPath = Join-Path $repo 'infra\db\init'
$dockerArgs = @(
'run', '-d',
'--name', 'vignette-dev-db',
'-p', ('{0}:5432' -f $port),
'-e', ('POSTGRES_USER={0}' -f $u),
'-e', ('POSTGRES_PASSWORD={0}' -f $p),
'--health-cmd', ('pg_isready -U {0}' -f $ownerUser),
'--health-interval', '10s',
'--health-timeout', '5s',
'--health-retries', '5',
'-e', ('POSTGRES_USER={0}' -f $ownerUser),
'-e', ('POSTGRES_PASSWORD={0}' -f $ownerPassword),
'-e', ('POSTGRES_DB={0}' -f $db),
'-e', 'APP_DB_USER=vignette_app',
'-e', 'APP_DB_PASSWORD=vignette_app',
'-e', ('APP_DB_USER={0}' -f $appUser),
'-e', ('APP_DB_PASSWORD={0}' -f $appPassword),
'-v', ('{0}:/docker-entrypoint-initdb.d:ro' -f $initPath),
'pgvector/pgvector:pg16'
)
@ -130,9 +215,10 @@ function Ensure-DevDb {
for ($i = 0; $i -lt 24; $i++) {
Start-Sleep -Seconds 2
docker exec vignette-dev-db pg_isready -U $u 1>$null 2>$null
docker exec vignette-dev-db pg_isready -U $ownerUser 1>$null 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Host ' OK db (Postgres 준비됨, init 스키마 적용)'
Test-DevDbRoleSafety $ownerUser $db
Start-Sleep -Seconds 1
return
}
@ -151,6 +237,7 @@ Start-Sleep -Seconds 2
if (-not $NoDb) {
Write-Host '[DB] Postgres(pgvector) 보장...'
Warn-ComposeEnvReadiness
Ensure-DevDb
}

View file

@ -0,0 +1,324 @@
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
API_ROOT = REPO_ROOT / "apps" / "api"
sys.path.insert(0, str(API_ROOT))
import asyncpg # noqa: E402
from app.services.dataset_export import ( # noqa: E402
APPROVED_EXPORT_STATUS,
DRY_RUN_EXPORT_STATUS,
ExportKeyMaps,
build_dataset_record,
build_manifest,
cohen_kappa,
intraclass_correlation,
scan_for_pii,
sha256_file,
validate_manifest_gate,
write_jsonl,
)
TURN_QUERY = """
SELECT
s.id::text AS session_id,
s.learner_id::text AS learner_id,
COALESCE(pc.code, s.persona_id::text, 'unknown') AS persona_code,
s.started_at AS session_started_at,
s.ended_at AS session_ended_at,
t.id::text AS turn_id,
t.seq,
t.speaker,
t.stage,
t.text_masked,
t.created_at,
COALESCE(
jsonb_agg(DISTINCT jsonb_build_object(
'dimension', fs.dimension,
'score', fs.score,
'rationale', fs.rationale,
'loop', fs.loop
)) FILTER (WHERE fs.dimension IS NOT NULL),
'[]'::jsonb
) AS feedback_scores,
COALESCE(
jsonb_agg(DISTINCT jsonb_build_object(
'code', tech.code,
'display_name', tech.display_name,
'category', tech.category
)) FILTER (WHERE tech.code IS NOT NULL),
'[]'::jsonb
) AS techniques,
COALESCE(
jsonb_agg(DISTINCT jsonb_build_object(
'code', cs.code,
'display_name', cs.display_name
)) FILTER (WHERE cs.code IS NOT NULL),
'[]'::jsonb
) AS client_states,
COALESCE(
jsonb_agg(DISTINCT jsonb_build_object(
'kind', sc.kind,
'text', sc.text,
'intent_deviation', sc.intent_deviation
)) FILTER (WHERE sc.id IS NOT NULL),
'[]'::jsonb
) AS supervisor_comments
FROM app.turns t
JOIN app.sessions s ON s.id = t.session_id
JOIN app.app_user u ON u.user_id = s.learner_id
LEFT JOIN app.persona_card pc
ON pc.persona_id = s.persona_id AND pc.version = s.persona_version
LEFT JOIN app.feedback_scores fs ON fs.turn_id = t.id
LEFT JOIN app.turn_technique tt ON tt.turn_id = t.id
LEFT JOIN app.technique_label_def tech ON tech.label_id = tt.label_id
LEFT JOIN app.turn_client_state tcs ON tcs.turn_id = t.id
LEFT JOIN app.client_state_def cs ON cs.label_id = tcs.label_id
LEFT JOIN app.supervisor_comment sc ON sc.turn_id = t.id
WHERE u.is_active
AND t.text_masked IS NOT NULL
AND btrim(t.text_masked) <> ''
AND ($1::timestamptz IS NULL OR t.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR t.created_at < $2::timestamptz)
AND ($4::boolean = false OR u.cohort = $3::text)
GROUP BY s.id, s.learner_id, pc.code, t.id, t.seq, t.speaker, t.stage, t.text_masked, t.created_at
ORDER BY t.created_at, s.id, t.seq
LIMIT $5
"""
ANNOTATION_QUERY = """
SELECT
di.item_id::text AS item_id,
ar.round_id::text AS round_id,
a.annotator,
a.labels
FROM ds.annotation a
JOIN ds.annotation_round ar ON ar.round_id = a.round_id
JOIN ds.dataset_item di ON di.item_id = a.item_id
WHERE ar.dataset_id = $1
ORDER BY di.item_id, ar.round_id, a.annotation_id
"""
def parse_time(value: str | None) -> datetime | None:
if not value:
return None
normalized = value.replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed
def make_export_id() -> str:
return f"phase3-rl-seed-{datetime.now(UTC).strftime('%Y%m%d')}-001"
def row_to_dict(row: asyncpg.Record) -> dict[str, Any]:
return {key: row[key] for key in row.keys()}
async def fetch_rows(conn: asyncpg.Connection, args: argparse.Namespace) -> list[dict[str, Any]]:
rows = await conn.fetch(
TURN_QUERY,
parse_time(args.started_at),
parse_time(args.ended_at),
args.cohort_id,
args.apply_cohort_filter,
args.limit,
)
return [row_to_dict(row) for row in rows]
async def write_dataset_rows(
conn: asyncpg.Connection,
*,
dataset_name: str,
purpose: str,
raw_rows: list[dict[str, Any]],
records: list[dict[str, Any]],
) -> int:
dataset_id = await conn.fetchval(
"INSERT INTO ds.dataset (name, purpose) VALUES ($1, $2) RETURNING dataset_id",
dataset_name,
purpose,
)
for raw_row, record in zip(raw_rows, records):
await conn.execute(
"INSERT INTO ds.dataset_item (dataset_id, turn_id, payload) VALUES ($1, $2::uuid, $3::jsonb)",
dataset_id,
raw_row["turn_id"],
json.dumps(record, ensure_ascii=False),
)
return int(dataset_id)
async def fetch_agreement(conn: asyncpg.Connection, dataset_id: int | None, args: argparse.Namespace) -> dict[str, Any]:
if dataset_id is None:
return {"kappa": None, "icc": None, "gold_status": "not_gold"}
rows = [row_to_dict(row) for row in await conn.fetch(ANNOTATION_QUERY, dataset_id)]
kappa = cohen_kappa(rows, args.kappa_label)
icc = intraclass_correlation(rows, args.icc_label)
gold_status = "gold_candidate" if kappa is not None and kappa >= 0.60 and icc is not None and icc >= 0.75 else "not_gold"
return {"kappa": kappa, "icc": icc, "gold_status": gold_status}
async def insert_manifest_row(
conn: asyncpg.Connection,
*,
dataset_id: int,
agreement: dict[str, Any],
jsonl_path: str,
) -> None:
await conn.execute(
"""
INSERT INTO ds.export_manifest (dataset_id, iaa_kappa, iaa_icc, jsonl_path)
VALUES ($1, $2, $3, $4)
""",
dataset_id,
agreement.get("kappa"),
agreement.get("icc"),
jsonl_path,
)
async def run(args: argparse.Namespace) -> int:
if args.export_status == APPROVED_EXPORT_STATUS and not args.allow_approved:
raise SystemExit("--export-status approved_for_recursive_learning_seed requires --allow-approved")
if args.write_manifest_row and not args.write_dataset:
raise SystemExit("--write-manifest-row requires --write-dataset")
output_root = Path(args.output_root).resolve()
export_dir = output_root / "03-export"
jsonl_path = export_dir / "anonymized_dataset.jsonl"
manifest_path = export_dir / "export_manifest.json"
conn = await asyncpg.connect(args.database_url)
try:
raw_rows = await fetch_rows(conn, args)
keys = ExportKeyMaps()
records = [
build_dataset_record(
row,
item_index=index,
export_manifest_id=args.export_id,
keys=keys,
pii_scan_status="pending",
)
for index, row in enumerate(raw_rows, start=1)
]
pii_findings: list[dict[str, Any]] = []
for record in records:
for finding in scan_for_pii(record):
pii_findings.append({"item_id": record["item_id"], **finding})
if pii_findings and args.fail_on_pii:
raise SystemExit(f"PII scan found {len(pii_findings)} finding(s)")
pii_status = "pass" if not pii_findings else "fail"
for record in records:
record["privacy"]["pii_scan_status"] = pii_status
write_jsonl(records, jsonl_path)
digest = sha256_file(jsonl_path)
dataset_id: int | None = None
if args.write_dataset:
dataset_id = await write_dataset_rows(
conn,
dataset_name=args.dataset_name,
purpose=args.purpose,
raw_rows=raw_rows,
records=records,
)
agreement = await fetch_agreement(conn, dataset_id, args)
approvals = {
"data_steward": args.data_steward,
"legal_or_privacy_reviewer": args.legal_or_privacy_reviewer,
"technical_operator": args.technical_operator or os.environ.get("USERNAME", ""),
"approved_at": args.approved_at,
}
manifest = build_manifest(
export_id=args.export_id,
dataset_name=args.dataset_name,
export_status=args.export_status,
purpose=args.purpose,
records=records,
jsonl_path=str(jsonl_path.relative_to(output_root)).replace("\\", "/"),
jsonl_sha256=digest,
pii_findings=pii_findings,
participants_included=len(keys.participant),
cohort_id=args.cohort_id,
consent_version=args.consent_version,
agreement=agreement,
approvals=approvals,
known_limitations=args.known_limitation,
)
validate_manifest_gate(manifest)
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
if args.write_manifest_row and dataset_id is not None:
await insert_manifest_row(
conn,
dataset_id=dataset_id,
agreement=agreement,
jsonl_path=str(jsonl_path),
)
finally:
await conn.close()
print(json.dumps({"manifest": str(manifest_path), "jsonl": str(jsonl_path), "rows": len(records)}, ensure_ascii=False))
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Export a Phase 3 recursive-learning JSONL dry-run dataset.")
parser.add_argument("--database-url", default=os.environ.get("DATABASE_URL"), required=os.environ.get("DATABASE_URL") is None)
parser.add_argument("--output-root", default=str(REPO_ROOT / "data" / "phase3-dry-run"))
parser.add_argument("--export-id", default=make_export_id())
parser.add_argument("--dataset-name", default="vignette_phase3_recursive_learning_seed")
parser.add_argument("--purpose", default="recursive-learning seed dataset for education simulator improvement")
parser.add_argument("--export-status", choices=[DRY_RUN_EXPORT_STATUS, "blocked", APPROVED_EXPORT_STATUS], default=DRY_RUN_EXPORT_STATUS)
parser.add_argument("--allow-approved", action="store_true")
parser.add_argument("--started-at")
parser.add_argument("--ended-at")
parser.add_argument("--limit", type=int, default=1000)
parser.add_argument("--cohort-id", default="phase3")
parser.add_argument("--apply-cohort-filter", action="store_true")
parser.add_argument("--consent-version", default="")
parser.add_argument("--data-steward", default="")
parser.add_argument("--legal-or-privacy-reviewer", default="")
parser.add_argument("--technical-operator", default="")
parser.add_argument("--approved-at", default="")
parser.add_argument("--known-limitation", action="append", default=[])
parser.add_argument("--fail-on-pii", action="store_true")
parser.add_argument("--kappa-label", default="appropriateness")
parser.add_argument("--icc-label", default="rapport_signal")
parser.add_argument("--write-dataset", action="store_true")
parser.add_argument("--write-manifest-row", action="store_true")
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
return asyncio.run(run(args))
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -2,6 +2,7 @@ param(
[string]$Workspace = "D:\workspace\vignette",
[string]$TaskName = "VignettePublicRuntimeWatchdog",
[int]$IntervalMinutes = 5,
[string[]]$AdditionalPublicHealthUrls = @(),
[switch]$SkipPublicHealth,
[switch]$SkipCloudflaredRestart,
[switch]$RunNow
@ -33,6 +34,10 @@ if ($SkipPublicHealth) {
if ($SkipCloudflaredRestart) {
$actionArguments += "-SkipCloudflaredRestart"
}
if ($AdditionalPublicHealthUrls.Count -gt 0) {
$escapedUrls = $AdditionalPublicHealthUrls | ForEach-Object { "`"$_`"" }
$actionArguments += "-AdditionalPublicHealthUrls $($escapedUrls -join ',')"
}
$action = New-ScheduledTaskAction `
-Execute $powershell `
@ -73,6 +78,9 @@ Register-ScheduledTask -TaskName $TaskName -InputObject $task -Force | Out-Null
Write-Output "Installed scheduled task '$TaskName' for $userId"
Write-Output "Action: $powershell $($actionArguments -join ' ')"
Write-Output "Interval: every $IntervalMinutes minute(s), plus at user logon"
if ($AdditionalPublicHealthUrls.Count -gt 0) {
Write-Output "Additional public health URLs: $($AdditionalPublicHealthUrls -join ', ')"
}
if ($RunNow) {
Start-ScheduledTask -TaskName $TaskName

View file

@ -188,6 +188,7 @@ $env:ENGINE_MODE = "claude_cli"
$env:AUTH_DEV_LOGIN_ENABLED = "false"
$env:AUTO_SEED_PERSONAS = "false"
$env:ALLOW_SEED_PERSONA_FALLBACK = "false"
$env:VIGNETTE_VOICE_POC_SAMPLE_TTS = "false"
$env:FRONTEND_BASE_URL = "https://vignette.chanpaca.net"
$frontendOrigins = @("https://vignette.chanpaca.net", "https://vnet.18ka.net", "https://vignette-b1q.pages.dev")
$localViteOrigins = @()

View file

@ -7,7 +7,7 @@
[string]$Cloudflared = "$env:LOCALAPPDATA\Microsoft\WinGet\Links\cloudflared.exe",
[string]$CloudflaredConfig = "$env:USERPROFILE\.cloudflared\vignette-config.yml",
[string]$PublicHealthUrl = "https://api-vignette.chanpaca.net/health",
[string[]]$AdditionalPublicHealthUrls = @("https://api-vnet.18ka.net/health"),
[string[]]$AdditionalPublicHealthUrls = @(),
[string]$LogPath = "",
[switch]$CheckOnly,
[switch]$SkipPublicHealth,