세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터

- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등
- 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리
- 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치)
- 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
This commit is contained in:
Yun Chan 2026-07-02 02:50:36 +09:00
parent 7c41c3ce79
commit 778e8526d4
108 changed files with 6457 additions and 455 deletions

View file

@ -0,0 +1,136 @@
# boot-public-runtime.ps1
# 목적: PC 재부팅/재로그온 후 퍼블릭 런타임을 자동으로 복구한다.
# 순서: Docker Desktop 데몬 대기 -> postgres(vignette-dev-db) 기동 ->
# API 가 이미 healthy 면 스킵, 아니면 start-public-runtime.ps1(-SkipWebRestart) 로
# 엔진 게이트웨이(9099) + API(8001) + cloudflared 터널을 올린다.
# 멱등: 어느 단계든 이미 살아있으면 건드리지 않는다. 수동으로 여러 번 실행해도 안전.
#
# 등록(로그온 시 자동 실행, 숨김 창):
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts\register-boot-task.ps1
# 또는 수동:
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts\boot-public-runtime.ps1
param(
[string]$Workspace = "D:\workspace\vignette",
[string]$DockerDesktop = "C:\Program Files\Docker\Docker\Docker Desktop.exe",
[int]$DaemonTimeoutSec = 360,
[int]$DbTimeoutSec = 90,
[int]$DbPort = 55432,
[int]$ApiPort = 8001,
[string]$BootLog = "D:\workspace\vignette\boot-public-runtime.log"
)
$ErrorActionPreference = "Continue" # 부트 스크립트는 끝까지 로깅하고 종료한다.
function Write-BootLog([string]$Message) {
$line = "[{0}] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message
try {
Add-Content -LiteralPath $BootLog -Value $line -Encoding UTF8
} catch {
# 로그 파일이 잠겨도 부팅은 계속한다.
}
}
function Test-DockerDaemon([int]$TimeoutSec = 10) {
try {
$ver = & docker.exe info --format '{{.ServerVersion}}' 2>$null
if ($LASTEXITCODE -eq 0 -and $ver) { return $true }
} catch {}
return $false
}
function Test-Tcp([string]$Host_, [int]$Port) {
try {
$t = (Test-NetConnection -ComputerName $Host_ -Port $Port -WarningAction SilentlyContinue)
return [bool]$t.TcpTestSucceeded
} catch { return $false }
}
function Test-ApiHealthy {
# HttpWebRequest + Proxy=$null: WININET/시스템 프록시에 영향받지 않는 가장 직결적인 검사.
# 비대화형 스케줄러 컨텍스트에서도 127.0.0.1 로 직접 연결한다. 3회 재시도.
for ($i = 1; $i -le 3; $i++) {
try {
$req = [System.Net.HttpWebRequest]::Create("http://127.0.0.1:$ApiPort/health")
$req.Timeout = 5000
$req.ReadWriteTimeout = 5000
$req.Proxy = $null
$resp = $req.GetResponse()
$reader = New-Object System.IO.StreamReader($resp.GetResponseStream())
$body = $reader.ReadToEnd()
$reader.Close(); $resp.Close()
$h = $body | ConvertFrom-Json
if ($h.environment -eq "prod" -and $h.db -eq $true -and $h.engine -eq $true) { return $true }
Write-BootLog (" health probe attempt {0}: not-healthy body={1}" -f $i, $body)
return $false
} catch {
Write-BootLog (" health probe attempt {0} failed: {1}" -f $i, $_.Exception.Message)
Start-Sleep -Seconds 2
}
}
return $false
}
Write-BootLog "================ boot start ================"
# 1) Docker 데몬(내려가 있으면 Docker Desktop 기동 후 대기)
if (-not (Test-DockerDaemon)) {
Write-BootLog "docker daemon down; launching Docker Desktop"
if (Test-Path -LiteralPath $DockerDesktop) {
Start-Process -FilePath $DockerDesktop | Out-Null
} else {
Write-BootLog "ERROR: Docker Desktop.exe not found at $DockerDesktop"
exit 1
}
$deadline = (Get-Date).AddSeconds($DaemonTimeoutSec)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds 5
if (Test-DockerDaemon) { break }
}
}
if (-not (Test-DockerDaemon)) {
Write-BootLog "ERROR: docker daemon did not come up within ${DaemonTimeoutSec}s"
exit 1
}
Write-BootLog "docker daemon up"
# 2) postgres 컨테이너(restart 정책 백업 + 명시 기동) 후 포트 대기
$null = & docker.exe update --restart unless-stopped vignette-dev-db 2>$null
$null = & docker.exe start vignette-dev-db 2>$null
$dbDeadline = (Get-Date).AddSeconds($DbTimeoutSec)
while ((Get-Date) -lt $dbDeadline) {
if (Test-Tcp -Host_ "127.0.0.1" -Port $DbPort) { break }
Start-Sleep -Seconds 3
}
if (-not (Test-Tcp -Host_ "127.0.0.1" -Port $DbPort)) {
Write-BootLog "ERROR: postgres not listening on 127.0.0.1:$DbPort"
exit 1
}
Write-BootLog "postgres 127.0.0.1:$DbPort up"
# 3) 엔진/API/cloudflared — 이미 healthy 면 스킵(불필요한 재시작/다운타임 방지)
if (Test-ApiHealthy) {
Write-BootLog "API already healthy on $ApiPort; skipping engine/api/cloudflared restart"
} else {
$pub = Join-Path $Workspace "scripts\start-public-runtime.ps1"
if (-not (Test-Path -LiteralPath $pub)) {
Write-BootLog "ERROR: start-public-runtime.ps1 not found at $pub"
exit 1
}
Write-BootLog "running start-public-runtime.ps1 -SkipWebRestart"
$out = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $pub -SkipWebRestart 2>&1
$out | ForEach-Object { Write-BootLog (" pub> " + $_) }
if ($LASTEXITCODE -ne 0) {
Write-BootLog "ERROR: start-public-runtime.ps1 exit $LASTEXITCODE"
exit 1
}
}
# 4) 최종 확인
if (Test-ApiHealthy) {
Write-BootLog "boot OK: API healthy on $ApiPort"
exit 0
} else {
Write-BootLog "WARN: boot finished but API health check failed — see apps/api/api.public.err.log"
exit 2
}

View file

@ -24,6 +24,7 @@ from app.services.phase3_kpi_contract import ( # noqa: E402
PHASE3_KPI_METRICS,
PREPOST_CSV_PATH,
)
from app.services.dataset_export import DATASET_ITEM_SCHEMA, scan_for_pii # noqa: E402
FORBIDDEN_HEADER_TERMS = {
@ -172,6 +173,31 @@ MANIFEST_KEYS = {
"source_window",
}
DATASET_ITEM_REQUIRED_KEYS = {
"client_states",
"feedback_scores",
"item_id",
"participant_key",
"persona_id",
"privacy",
"schema",
"session_key",
"source_refs",
"speaker",
"stage",
"supervisor_comments",
"techniques",
"text_masked",
"turn_key",
}
DATASET_ITEM_ARRAY_KEYS = {
"client_states",
"feedback_scores",
"supervisor_comments",
"techniques",
}
class Report:
def __init__(self) -> None:
@ -357,6 +383,128 @@ def numeric_value(value: Any) -> float | None:
return None
def _dataset_jsonl_issue(report: Report, approved: bool, message: str) -> None:
if approved:
report.error(message)
else:
report.warn(message)
def validate_dataset_jsonl(
path: Path,
rel_path: str,
report: Report,
*,
expected_rows: Any,
approved: bool,
) -> None:
row_count = 0
try:
with path.open("r", encoding="utf-8", newline="\n") as handle:
for line_number, raw_line in enumerate(handle, start=1):
line = raw_line.strip()
if not line:
continue
row_count += 1
try:
record = json.loads(line)
except json.JSONDecodeError as exc:
_dataset_jsonl_issue(
report,
approved,
f"{rel_path}:{line_number}: invalid JSONL record: {exc.msg}",
)
continue
if not isinstance(record, dict):
_dataset_jsonl_issue(
report,
approved,
f"{rel_path}:{line_number}: dataset item must be a JSON object",
)
continue
missing = sorted(DATASET_ITEM_REQUIRED_KEYS - set(record))
if missing:
_dataset_jsonl_issue(
report,
approved,
f"{rel_path}:{line_number}: dataset item missing keys: {', '.join(missing)}",
)
if record.get("schema") != DATASET_ITEM_SCHEMA:
_dataset_jsonl_issue(
report,
approved,
f"{rel_path}:{line_number}: dataset item schema must be {DATASET_ITEM_SCHEMA}",
)
if not isinstance(record.get("text_masked"), str) or not record.get("text_masked", "").strip():
_dataset_jsonl_issue(
report,
approved,
f"{rel_path}:{line_number}: text_masked must be a non-empty string",
)
for key in DATASET_ITEM_ARRAY_KEYS:
if key in record and not isinstance(record[key], list):
_dataset_jsonl_issue(
report,
approved,
f"{rel_path}:{line_number}: {key} must be an array",
)
source_refs = record.get("source_refs")
if source_refs is not None and not isinstance(source_refs, dict):
_dataset_jsonl_issue(
report,
approved,
f"{rel_path}:{line_number}: source_refs must be an object",
)
privacy = record.get("privacy")
if not isinstance(privacy, dict):
_dataset_jsonl_issue(
report,
approved,
f"{rel_path}:{line_number}: privacy must be an object",
)
else:
if privacy.get("direct_identifiers_removed") is not True:
_dataset_jsonl_issue(
report,
approved,
f"{rel_path}:{line_number}: privacy.direct_identifiers_removed must be true",
)
if not privacy.get("pii_scan_status"):
_dataset_jsonl_issue(
report,
approved,
f"{rel_path}:{line_number}: privacy.pii_scan_status is required",
)
if privacy.get("consent_scope") != "recursive_learning_seed":
_dataset_jsonl_issue(
report,
approved,
f"{rel_path}:{line_number}: privacy.consent_scope must be recursive_learning_seed",
)
findings = scan_for_pii(record)
if findings:
kinds = ", ".join(sorted({str(finding.get("kind")) for finding in findings}))
_dataset_jsonl_issue(
report,
approved,
f"{rel_path}:{line_number}: dataset item contains blocked identifier evidence: {kinds}",
)
except UnicodeDecodeError:
_dataset_jsonl_issue(report, approved, f"{rel_path}: not valid UTF-8")
return
except OSError as exc:
_dataset_jsonl_issue(report, approved, f"{rel_path}: cannot read dataset JSONL: {exc}")
return
if isinstance(expected_rows, int) and row_count != expected_rows:
_dataset_jsonl_issue(
report,
approved,
f"{rel_path}: rows mismatch for {path.name}: manifest={expected_rows} actual={row_count}",
)
def validate_manifest_file_entry(
root: Path,
rel_path: str,
@ -386,18 +534,33 @@ def validate_manifest_file_entry(
except ValueError:
report.error(f"{rel_path}: file path escapes evidence root: {file_path}")
return
if not approved:
return
if not candidate.exists():
report.error(f"{rel_path}: approved file missing: {file_path}")
if approved:
report.error(f"{rel_path}: approved file missing: {file_path}")
else:
report.warn(f"{rel_path}: file listed but missing: {file_path}")
return
if not candidate.is_file():
report.error(f"{rel_path}: approved file path is not a file: {file_path}")
if approved:
report.error(f"{rel_path}: approved file path is not a file: {file_path}")
else:
report.warn(f"{rel_path}: file path is not a file: {file_path}")
return
if isinstance(expected_sha, str) and expected_sha:
actual_sha = sha256_file(candidate)
if actual_sha != expected_sha:
report.error(f"{rel_path}: sha256 mismatch for {file_path}")
if approved:
report.error(f"{rel_path}: sha256 mismatch for {file_path}")
else:
report.warn(f"{rel_path}: sha256 mismatch for {file_path}")
if item.get("schema") == DATASET_ITEM_SCHEMA or str(file_path).endswith(".jsonl"):
validate_dataset_jsonl(
candidate,
rel_path,
report,
expected_rows=item.get("rows"),
approved=True,
)
def validate_manifest(root: Path, report: Report) -> None:

View file

@ -70,7 +70,6 @@ SELECT
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
@ -87,8 +86,10 @@ 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 u.consent_at IS NOT NULL
AND t.text_masked IS NOT NULL
AND btrim(t.text_masked) <> ''
AND 'client' = ANY(t.visible_to)
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)

View file

@ -0,0 +1,55 @@
# register-boot-task.ps1
# Registers the VignettePublicRuntime scheduled task to run at user logon
# (hidden window). The boot script (scripts/boot-public-runtime.ps1) does the
# actual recovery. Re-run to refresh (-Force). Remove with:
# Unregister-ScheduledTask -TaskName VignettePublicRuntime -Confirm:$false
#
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts\register-boot-task.ps1
$ErrorActionPreference = "Stop"
$TaskName = "VignettePublicRuntime"
$BootScript = "D:\workspace\vignette\scripts\boot-public-runtime.ps1"
$UserName = "$env:USERDOMAIN\$env:USERNAME"
if (-not (Test-Path -LiteralPath $BootScript)) {
throw "boot script not found: $BootScript"
}
$action = New-ScheduledTaskAction `
-Execute "powershell.exe" `
-Argument ("-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"" + $BootScript + "`"")
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $UserName
# Interactive: runs in the user session (Start-Process hidden window / Docker Desktop GUI work).
# Limited: no admin rights needed.
$principal = New-ScheduledTaskPrincipal `
-UserId $env:USERNAME `
-LogonType Interactive `
-RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 15)
Register-ScheduledTask `
-TaskName $TaskName `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Description "Vignette public runtime auto-recovery (Docker + postgres + engine/api + cloudflared) at logon" `
-Force | Out-Null
$task = Get-ScheduledTask -TaskName $TaskName
Write-Output ("Registered : " + $TaskName)
Write-Output ("State : " + $task.State)
Write-Output ("User : " + $principal.UserId)
Write-Output ("Trigger : AtLogOn (" + $UserName + ")")
Write-Output ("Command : " + $action.Execute + " " + $action.Argument)
Write-Output ""
Write-Output ("Run now : powershell -NoProfile -ExecutionPolicy Bypass -File " + $BootScript)
Write-Output ("Unregister : Unregister-ScheduledTask -TaskName " + $TaskName + " -Confirm:`$false")