세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·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:
parent
7c41c3ce79
commit
778e8526d4
108 changed files with 6457 additions and 455 deletions
136
scripts/boot-public-runtime.ps1
Normal file
136
scripts/boot-public-runtime.ps1
Normal 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
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
55
scripts/register-boot-task.ps1
Normal file
55
scripts/register-boot-task.ps1
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue