Add phase15 product smoke validation
This commit is contained in:
@@ -5234,3 +5234,74 @@
|
||||
- `python -m py_compile backend\app\api\chat.py backend\app\services\prompt_asset_service.py backend\app\services\prompt_golden_sample_evaluator.py backend\tools\seed_prompt_assets.py backend\tools\asset_inventory_gap_report.py backend\tests\test_prompt_asset_service.py backend\tests\test_chat_api.py backend\tests\test_prompt_assets_api.py`
|
||||
- `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_prompt_asset_service backend.tests.test_prompt_assets_api backend.tests.test_chat_api backend.tests.test_prompt_runtime_service -v`
|
||||
- `npm run lint`(frontend)
|
||||
|
||||
## 2026-05-24 Phase15 Prompt 资产远端种入、发布与 runtime 验证闭环
|
||||
|
||||
- 执行目标:
|
||||
- 将 `project-docs/phase15-prompt-assets/default-prompt-assets.json` 中的 `36` 个 Prompt 资产正式种入远端运行库。
|
||||
- 完成本地黄金样本评测、发布,并确认 `PromptRuntimeService` 能解析到发布态资产。
|
||||
- 远端执行命令:
|
||||
- `$env:SSHPASS='***'; powershell -NoProfile -ExecutionPolicy Bypass -File scripts\run-prompt-assets-seed-publish-eval.ps1 -UseSshpass -EvaluationMode local`
|
||||
- 执行结果:
|
||||
- 输出:`PROMPT_ASSET_SEED_PASS`
|
||||
- 产物目录:`project-docs/qa-artifacts/prompt-asset-seed-publish-eval/2026-05-24T17-08-22`
|
||||
- 本轮处理 Prompt 资产:`36`
|
||||
- 新建:`30`
|
||||
- 复用:`6`
|
||||
- 更新:`0`
|
||||
- 完成评测:`36`
|
||||
- 发布:`36`
|
||||
- 失败:`0`
|
||||
- 远端发布后快照:
|
||||
- `prompt_assets = 36`
|
||||
- `prompt_assets_published = 36`
|
||||
- `prompt_asset_versions = 36`
|
||||
- `prompt_asset_versions_published = 36`
|
||||
- `contract_generation = 21/21 published`
|
||||
- `contract_review = 7/7 published`
|
||||
- `business_auxiliary = 4/4 published`
|
||||
- `legal_chat = 1/1 published`
|
||||
- `clause_recommendation = 1/1 published`
|
||||
- `wording_rewrite = 1/1 published`
|
||||
- `regulation_impact = 1/1 published`
|
||||
- runtime 解析验证:
|
||||
- 证据:`project-docs/qa-artifacts/prompt-asset-seed-publish-eval/2026-05-24T17-08-22/prompt-runtime-smoke.json`
|
||||
- `runtime_total = 36`
|
||||
- `runtime_passed = 36`
|
||||
- `runtime_failed_count = 0`
|
||||
- `source = published_prompt_asset:36`
|
||||
- `match_strategy = preferred_key:36`
|
||||
- 当前闭环结论:
|
||||
- Phase15 的默认生成 Prompt、专项生成 Prompt、专项审查 Prompt、业务辅助 Prompt 已全部进入远端发布态。
|
||||
- 运行时按 key 优先解析时,36 个资产均命中 `published_prompt_asset`,不再依赖本地 fallback。
|
||||
- 下一步可进入产品路径烟测:重点验证 `investment_agreement`、`valuation_adjustment_agreement`、`shareholder_resolution`、`power_of_attorney` 以及 4 个 `business_auxiliary` 意图路由。
|
||||
|
||||
## 2026-05-24 Phase15 产品路径烟测闭环
|
||||
|
||||
- 执行目标:
|
||||
- 验证 4 个重点合同/文书生成产品路径:`investment_agreement`、`valuation_adjustment_agreement`、`shareholder_resolution`、`power_of_attorney`。
|
||||
- 验证 4 个 `business_auxiliary` 意图路由:履约义务提取、工商/备案材料清单、谈判策略、法律意见书强边界兜底。
|
||||
- 同步验证远端前端 `/documents/new/<type>` 页面能在登录态下真实打开并无 console error。
|
||||
- 本轮修复:
|
||||
- 新增 `scripts/run-phase15-product-smoke.ps1`,作为可复跑产品烟测脚本。
|
||||
- 修复 restore Docker 部署脚本,前端 build/start 显式注入 `BACKEND_BASE_URL=http://192.168.3.90:8001` 和 `NEXT_PUBLIC_API_BASE_URL=/api/v1`,避免 `/api/v1/*` rewrite 默认打到 `http://backend:8000`。
|
||||
- 烟测前置种入默认 `legal_document_types`、`element_schemas` 和默认模板,确保产品页面和生成服务使用同一运行库资产。
|
||||
- 执行命令:
|
||||
- `$env:SSHPASS='***'; powershell -NoProfile -ExecutionPolicy Bypass -File scripts\deploy-ai-contract-restore-docker.ps1 -UseSshpass -SkipLegalChangeWorker -AllowDirtyWorktree`
|
||||
- `$env:SSHPASS='***'; powershell -NoProfile -ExecutionPolicy Bypass -File scripts\run-phase15-product-smoke.ps1 -UseSshpass`
|
||||
- 执行结果:
|
||||
- 部署:`RESTORE_DOCKER_DEPLOY_WARN`,产物:`project-docs/qa-artifacts/restore-docker-deploy/2026-05-24T18-13-40`;WARN 来源为开发部署允许脏工作区。
|
||||
- 产品烟测:`PHASE15_PRODUCT_SMOKE_PASS`,产物:`project-docs/qa-artifacts/phase15-product-smoke/2026-05-24T18-21-37`。
|
||||
- 烟测指标:
|
||||
- 合同/文书生成路由:`4/4`
|
||||
- `business_auxiliary` 路由:`4/4`
|
||||
- 前端文书新建页:`4/4`
|
||||
- 必需失败:`0`
|
||||
- 运行库种入快照:
|
||||
- 默认文书类型:新建 `7`,复用 `11`
|
||||
- 默认 element schema:新建 `7`,复用 `11`,跳过 `0`
|
||||
- 默认模板:新建 `0`,复用 `18`
|
||||
- 结论:
|
||||
- 4 个重点产品生成路径均能命中远端发布态专项 Prompt,并生成合同/文书草案。
|
||||
- 4 个业务辅助意图均能命中远端发布态 `business_auxiliary` Prompt。
|
||||
- 前端 4 个新建文书页面均能在远端登录态下打开,最终 URL 正确,console clean。
|
||||
|
||||
@@ -47,12 +47,15 @@ $summaryMd = Join-Path $artifactDir "restore-docker-deploy-summary.md"
|
||||
$gitStatusPath = Join-Path $artifactDir "git-status.txt"
|
||||
$backendArchiveName = "ai-contract-backend-restore-$timestamp.tgz"
|
||||
$frontendArchiveName = "ai-contract-frontend-restore-$timestamp.tgz"
|
||||
$projectDocsArchiveName = "ai-contract-project-docs-phase13-$timestamp.tgz"
|
||||
$remoteScriptName = "ai-contract-restore-deploy-$timestamp.sh"
|
||||
$localBackendArchive = Join-Path $artifactDir $backendArchiveName
|
||||
$localFrontendArchive = Join-Path $artifactDir $frontendArchiveName
|
||||
$localProjectDocsArchive = Join-Path $artifactDir $projectDocsArchiveName
|
||||
$localRemoteScript = Join-Path $artifactDir $remoteScriptName
|
||||
$remoteBackendArchive = "/tmp/$backendArchiveName"
|
||||
$remoteFrontendArchive = "/tmp/$frontendArchiveName"
|
||||
$remoteProjectDocsArchive = "/tmp/$projectDocsArchiveName"
|
||||
$remoteScriptPath = "/tmp/$remoteScriptName"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
|
||||
@@ -243,6 +246,7 @@ set -euo pipefail
|
||||
project=__REMOTE_PROJECT_PATH__
|
||||
backend_archive=__REMOTE_BACKEND_ARCHIVE__
|
||||
frontend_archive=__REMOTE_FRONTEND_ARCHIVE__
|
||||
project_docs_archive=__REMOTE_PROJECT_DOCS_ARCHIVE__
|
||||
backup_root=__REMOTE_BACKUP_ROOT__
|
||||
stamp=__STAMP__
|
||||
backend_container=__BACKEND_CONTAINER__
|
||||
@@ -250,6 +254,7 @@ frontend_container=__FRONTEND_CONTAINER__
|
||||
worker_container=__WORKER_CONTAINER__
|
||||
backend_image=__BACKEND_IMAGE__
|
||||
frontend_image=__FRONTEND_IMAGE__
|
||||
backend_base_url=__BACKEND_BASE_URL__
|
||||
backend_port=__BACKEND_PORT__
|
||||
frontend_host_port=__FRONTEND_HOST_PORT__
|
||||
frontend_container_port=__FRONTEND_CONTAINER_PORT__
|
||||
@@ -276,13 +281,18 @@ test -f frontend/package.json
|
||||
test -f backend/.env
|
||||
test -s "$backend_archive"
|
||||
test -s "$frontend_archive"
|
||||
test -s "$project_docs_archive"
|
||||
docker image inspect "$backend_image" >/dev/null
|
||||
docker image inspect "$frontend_image" >/dev/null
|
||||
|
||||
backup_dir="$backup_root/$stamp"
|
||||
mkdir -p "$backup_dir"
|
||||
tar -czf "$backup_dir/backend-before-$stamp.tgz" backend
|
||||
tar -czf "$backup_dir/frontend-before-$stamp.tgz" frontend
|
||||
tar -czf "$backup_dir/frontend-before-$stamp.tgz" \
|
||||
--exclude='frontend/node_modules' \
|
||||
--exclude='frontend/.next' \
|
||||
--exclude='frontend/.next-dev-log' \
|
||||
frontend
|
||||
test -s "$backup_dir/backend-before-$stamp.tgz"
|
||||
test -s "$backup_dir/frontend-before-$stamp.tgz"
|
||||
cp backend/.env "$backup_dir/backend.env"
|
||||
@@ -308,6 +318,8 @@ rm -rf backend frontend
|
||||
mkdir -p backend frontend
|
||||
tar -xzf "$backend_archive" --strip-components=1 -C backend
|
||||
tar -xzf "$frontend_archive" --strip-components=1 -C frontend
|
||||
mkdir -p project-docs
|
||||
tar -xzf "$project_docs_archive" -C project-docs
|
||||
cp "$temp_root/backend.env" backend/.env
|
||||
if [ -f "$temp_root/frontend.env.local" ]; then cp "$temp_root/frontend.env.local" frontend/.env.local; fi
|
||||
if [ -d "$temp_root/frontend-node_modules" ]; then mv "$temp_root/frontend-node_modules" frontend/node_modules; fi
|
||||
@@ -315,8 +327,15 @@ if [ -d "$temp_root/frontend-node_modules" ]; then mv "$temp_root/frontend-node_
|
||||
test -f backend/app/main.py
|
||||
test -f backend/migrations/env.py
|
||||
test -f frontend/package.json
|
||||
test -f project-docs/phase13-legal-knowledge-template-library/contract-templates/equity-transfer/TPL-EQUITY-TRANSFER-001.md
|
||||
|
||||
docker run --rm -v "$project/frontend:/app" -w /app "$frontend_image" sh -lc 'if [ ! -d node_modules ]; then npm ci; fi; npm run build'
|
||||
docker run --rm \
|
||||
-e BACKEND_BASE_URL="$backend_base_url" \
|
||||
-e NEXT_PUBLIC_API_BASE_URL="/api/v1" \
|
||||
-v "$project/frontend:/app" \
|
||||
-w /app \
|
||||
"$frontend_image" \
|
||||
sh -lc 'if [ ! -d node_modules ]; then npm ci; fi; npm run build'
|
||||
|
||||
docker rm -f "$backend_container" >/dev/null 2>&1 || true
|
||||
docker rm -f "$frontend_container" >/dev/null 2>&1 || true
|
||||
@@ -331,6 +350,7 @@ docker run -d \
|
||||
--network host \
|
||||
--env-file "$project/backend/.env" \
|
||||
-v "$project/backend:/app" \
|
||||
-v "$project/project-docs:/project-docs:ro" \
|
||||
-v "$project/storage:/data/ai-contract-storage" \
|
||||
-w /app \
|
||||
"$backend_image" \
|
||||
@@ -340,6 +360,8 @@ docker run -d \
|
||||
--name "$frontend_container" \
|
||||
--restart unless-stopped \
|
||||
-p "$frontend_host_port:$frontend_container_port" \
|
||||
-e BACKEND_BASE_URL="$backend_base_url" \
|
||||
-e NEXT_PUBLIC_API_BASE_URL="/api/v1" \
|
||||
-v "$project/frontend:/app" \
|
||||
-w /app \
|
||||
"$frontend_image" \
|
||||
@@ -356,6 +378,7 @@ if [ "$skip_legal_change_worker" != "true" ]; then
|
||||
-e LEGAL_CHANGE_IMPACT_WORKER_INTERVAL_SECONDS="$worker_interval_seconds" \
|
||||
-e LEGAL_CHANGE_IMPACT_SEMANTIC_ENABLED=true \
|
||||
-v "$project/backend:/app" \
|
||||
-v "$project/project-docs:/project-docs:ro" \
|
||||
-v "$project/storage:/data/ai-contract-storage" \
|
||||
-w /app \
|
||||
"$backend_image" \
|
||||
@@ -405,6 +428,7 @@ echo "REMOTE_RESTORE_DEPLOY_BACKUP=$backup_dir"
|
||||
"__REMOTE_PROJECT_PATH__" = ConvertTo-ShellSingleQuote $RemoteProjectPath
|
||||
"__REMOTE_BACKEND_ARCHIVE__" = ConvertTo-ShellSingleQuote $remoteBackendArchive
|
||||
"__REMOTE_FRONTEND_ARCHIVE__" = ConvertTo-ShellSingleQuote $remoteFrontendArchive
|
||||
"__REMOTE_PROJECT_DOCS_ARCHIVE__" = ConvertTo-ShellSingleQuote $remoteProjectDocsArchive
|
||||
"__REMOTE_BACKUP_ROOT__" = ConvertTo-ShellSingleQuote $RemoteBackupRoot
|
||||
"__STAMP__" = ConvertTo-ShellSingleQuote $timestamp
|
||||
"__BACKEND_CONTAINER__" = ConvertTo-ShellSingleQuote $BackendContainerName
|
||||
@@ -412,6 +436,7 @@ echo "REMOTE_RESTORE_DEPLOY_BACKUP=$backup_dir"
|
||||
"__WORKER_CONTAINER__" = ConvertTo-ShellSingleQuote $WorkerContainerName
|
||||
"__BACKEND_IMAGE__" = ConvertTo-ShellSingleQuote $BackendImage
|
||||
"__FRONTEND_IMAGE__" = ConvertTo-ShellSingleQuote $FrontendImage
|
||||
"__BACKEND_BASE_URL__" = ConvertTo-ShellSingleQuote $BackendBaseUrl
|
||||
"__BACKEND_PORT__" = $BackendPort.ToString()
|
||||
"__FRONTEND_HOST_PORT__" = $FrontendHostPort.ToString()
|
||||
"__FRONTEND_CONTAINER_PORT__" = $FrontendContainerPort.ToString()
|
||||
@@ -549,6 +574,21 @@ if ($PlanOnly) {
|
||||
} else {
|
||||
Add-Check -Key "frontend_archive" -Label "frontend archive" -Status "fail" -Detail "frontend archive creation failed" -Meta @{ log = $frontendArchive.log }
|
||||
}
|
||||
|
||||
$projectDocsArchive = Invoke-LocalStep -Name "04-create-project-docs-archive" -Script {
|
||||
Push-Location (Join-Path $repoRoot "project-docs")
|
||||
try {
|
||||
& tar -czf $localProjectDocsArchive `
|
||||
phase13-legal-knowledge-template-library
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
if ($projectDocsArchive.exit_code -eq 0 -and (Test-Path $localProjectDocsArchive)) {
|
||||
Add-Check -Key "project_docs_archive" -Label "project docs archive" -Status "pass" -Detail "phase13 legal knowledge template archive created" -Meta @{ archive = $localProjectDocsArchive; bytes = (Get-Item $localProjectDocsArchive).Length; log = $projectDocsArchive.log }
|
||||
} else {
|
||||
Add-Check -Key "project_docs_archive" -Label "project docs archive" -Status "fail" -Detail "project docs archive creation failed" -Meta @{ log = $projectDocsArchive.log }
|
||||
}
|
||||
}
|
||||
|
||||
$failedBeforeUpload = @($checks | Where-Object { $_.required -and $_.status -eq "fail" })
|
||||
@@ -577,6 +617,13 @@ if ($PlanOnly) {
|
||||
} else {
|
||||
Add-Check -Key "upload_remote_script" -Label "upload remote script" -Status "fail" -Detail "remote restore script upload failed" -Meta @{ log = $uploadScript.log }
|
||||
}
|
||||
|
||||
$uploadProjectDocs = Invoke-ScpToRemote -Name "07-upload-project-docs-archive" -LocalPath $localProjectDocsArchive -RemotePath $remoteProjectDocsArchive
|
||||
if ($uploadProjectDocs.exit_code -eq 0) {
|
||||
Add-Check -Key "upload_project_docs" -Label "upload project docs archive" -Status "pass" -Detail "phase13 legal knowledge template archive uploaded" -Meta @{ remote_archive = $remoteProjectDocsArchive; log = $uploadProjectDocs.log }
|
||||
} else {
|
||||
Add-Check -Key "upload_project_docs" -Label "upload project docs archive" -Status "fail" -Detail "project docs archive upload failed" -Meta @{ log = $uploadProjectDocs.log }
|
||||
}
|
||||
}
|
||||
|
||||
$failedBeforeDeploy = @($checks | Where-Object { $_.required -and $_.status -eq "fail" })
|
||||
@@ -629,9 +676,10 @@ $summary = [ordered]@{
|
||||
auth_mode = $script:AuthMode
|
||||
local_backend_archive = $localBackendArchive
|
||||
local_frontend_archive = $localFrontendArchive
|
||||
remote_backend_archive = $remoteBackendArchive
|
||||
remote_frontend_archive = $remoteFrontendArchive
|
||||
remote_script = $remoteScriptPath
|
||||
remote_backend_archive = $remoteBackendArchive
|
||||
remote_frontend_archive = $remoteFrontendArchive
|
||||
remote_project_docs_archive = $remoteProjectDocsArchive
|
||||
remote_script = $remoteScriptPath
|
||||
targets = [ordered]@{
|
||||
server_host = $ServerHost
|
||||
remote_project_path = $RemoteProjectPath
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
param(
|
||||
[string]$RemoteHost = "192.168.3.90",
|
||||
[string]$RemoteUser = "root",
|
||||
[string]$BackendContainer = "ai-contract-backend-restore",
|
||||
[string]$BackendBaseUrl = "http://192.168.3.90:8001",
|
||||
[string]$FrontendBaseUrl = "http://192.168.3.90:3003",
|
||||
[string]$SshKeyPath = "$HOME\.ssh\id_rsa",
|
||||
[switch]$UseSshpass,
|
||||
[string]$TenantId = "tenant-demo-001",
|
||||
[string]$ArtifactRoot = "",
|
||||
[string]$BrowsePath = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Save-Json {
|
||||
param([string]$Path, [object]$Value)
|
||||
$Value | ConvertTo-Json -Depth 80 | Set-Content -Encoding UTF8 -Path $Path
|
||||
}
|
||||
|
||||
function Read-JsonFile {
|
||||
param([string]$Path)
|
||||
return Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function ConvertTo-ShellSingleQuote {
|
||||
param([string]$Value)
|
||||
return "'" + ($Value -replace "'", "'`"`"`'") + "'"
|
||||
}
|
||||
|
||||
function ConvertTo-JavascriptLiteral {
|
||||
param([object]$Value)
|
||||
return ($Value | ConvertTo-Json -Compress -Depth 50)
|
||||
}
|
||||
|
||||
function ConvertTo-JsSingleQuotedString {
|
||||
param([string]$Value)
|
||||
$escaped = $Value.Replace("\", "\\").Replace("'", "\'").Replace("`r", "\r").Replace("`n", "\n")
|
||||
return "'$escaped'"
|
||||
}
|
||||
|
||||
function Add-Check {
|
||||
param(
|
||||
[System.Collections.Generic.List[object]]$Checks,
|
||||
[string]$Key,
|
||||
[string]$Label,
|
||||
[ValidateSet("pass", "fail", "warn", "skip")]
|
||||
[string]$Status,
|
||||
[string]$Detail,
|
||||
[bool]$Required = $true,
|
||||
[hashtable]$Meta = @{}
|
||||
)
|
||||
|
||||
$Checks.Add([ordered]@{
|
||||
key = $Key
|
||||
label = $Label
|
||||
status = $Status
|
||||
required = $Required
|
||||
detail = $Detail
|
||||
meta = $Meta
|
||||
}) | Out-Null
|
||||
}
|
||||
|
||||
function Resolve-SshpassPath {
|
||||
$command = Get-Command sshpass.exe -ErrorAction SilentlyContinue
|
||||
if ($null -ne $command) {
|
||||
return $command.Source
|
||||
}
|
||||
|
||||
$candidates = @(
|
||||
(Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Packages\xhcoding.sshpass-win32_Microsoft.Winget.Source_8wekyb3d8bbwe\sshpass.exe")
|
||||
)
|
||||
foreach ($candidate in $candidates) {
|
||||
if (Test-Path $candidate) {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-BrowsePath {
|
||||
param([string]$RequestedPath)
|
||||
if (-not [string]::IsNullOrWhiteSpace($RequestedPath) -and (Test-Path -LiteralPath $RequestedPath -PathType Leaf)) {
|
||||
return (Resolve-Path -LiteralPath $RequestedPath).Path
|
||||
}
|
||||
|
||||
$candidates = @(
|
||||
(Join-Path $HOME ".claude\skills\gstack\browse\dist\browse.exe"),
|
||||
(Join-Path $HOME ".Codex\skills\gstack\browse\dist\browse.exe"),
|
||||
(Join-Path $HOME ".codex\skills\gstack\browse\dist\browse.exe")
|
||||
)
|
||||
foreach ($candidate in $candidates) {
|
||||
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
|
||||
return (Resolve-Path -LiteralPath $candidate).Path
|
||||
}
|
||||
}
|
||||
|
||||
$command = Get-Command browse.exe -ErrorAction SilentlyContinue
|
||||
if ($null -ne $command) {
|
||||
return $command.Source
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Invoke-LoggedCommand {
|
||||
param(
|
||||
[string]$Name,
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[string]$LogDir,
|
||||
[switch]$AllowFailure
|
||||
)
|
||||
|
||||
$logPath = Join-Path $LogDir "$Name.log"
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & $FilePath @Arguments 2>&1
|
||||
$exitCode = if ($LASTEXITCODE -is [int]) { $LASTEXITCODE } else { 0 }
|
||||
} finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
}
|
||||
|
||||
$outputText = ($output -join [Environment]::NewLine)
|
||||
$outputText | Set-Content -Encoding UTF8 -Path $logPath
|
||||
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||
throw "$Name failed with exit code $exitCode. See $logPath"
|
||||
}
|
||||
return [ordered]@{
|
||||
exit_code = $exitCode
|
||||
output = $outputText
|
||||
log = $logPath
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-JsonPost {
|
||||
param([string]$Url, [hashtable]$Body)
|
||||
$jsonBody = $Body | ConvertTo-Json -Depth 30
|
||||
return Invoke-RestMethod -Method Post -Uri $Url -ContentType "application/json" -Body $jsonBody -TimeoutSec 30
|
||||
}
|
||||
|
||||
function Invoke-HttpProbe {
|
||||
param([string]$Url)
|
||||
try {
|
||||
$response = Invoke-WebRequest -UseBasicParsing -Method Get -Uri $Url -TimeoutSec 30
|
||||
return [ordered]@{
|
||||
status_code = [int]$response.StatusCode
|
||||
ok = ([int]$response.StatusCode -ge 200 -and [int]$response.StatusCode -lt 300)
|
||||
content_length = if ($null -ne $response.Content) { $response.Content.Length } else { 0 }
|
||||
}
|
||||
} catch {
|
||||
$statusCode = $null
|
||||
if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
|
||||
$statusCode = [int]$_.Exception.Response.StatusCode
|
||||
}
|
||||
return [ordered]@{
|
||||
status_code = $statusCode
|
||||
ok = $false
|
||||
error = $_.Exception.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function New-SmokeAccount {
|
||||
param(
|
||||
[string]$ApiBase,
|
||||
[string]$RunStamp,
|
||||
[string]$ArtifactDir
|
||||
)
|
||||
|
||||
$username = ("codex-product-smoke-{0}@example.com" -f ($RunStamp -replace "[^0-9A-Za-z]", "")).ToLowerInvariant()
|
||||
$password = "Smoke!" + ([guid]::NewGuid().ToString("N").Substring(0, 18)) + "a1"
|
||||
$displayName = "Codex Product Smoke"
|
||||
|
||||
$registerResponse = Invoke-JsonPost -Url "$ApiBase/auth/register" -Body @{
|
||||
username = $username
|
||||
password = $password
|
||||
display_name = $displayName
|
||||
}
|
||||
if ([int]$registerResponse.code -ne 0) {
|
||||
throw "Smoke account register failed: $($registerResponse.message)"
|
||||
}
|
||||
|
||||
$loginResponse = Invoke-JsonPost -Url "$ApiBase/auth/login" -Body @{
|
||||
account = $username
|
||||
password = $password
|
||||
}
|
||||
if ([int]$loginResponse.code -ne 0) {
|
||||
throw "Smoke account login failed: $($loginResponse.message)"
|
||||
}
|
||||
|
||||
$safePayload = [ordered]@{
|
||||
username = $username
|
||||
user = $loginResponse.data.user
|
||||
token_present = -not [string]::IsNullOrWhiteSpace([string]$loginResponse.data.access_token)
|
||||
}
|
||||
Save-Json -Path (Join-Path $ArtifactDir "smoke-account.json") -Value $safePayload
|
||||
|
||||
return [ordered]@{
|
||||
username = $username
|
||||
access_token = [string]$loginResponse.data.access_token
|
||||
user = $loginResponse.data.user
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-BrowseCommand {
|
||||
param(
|
||||
[string]$Name,
|
||||
[string[]]$Arguments,
|
||||
[string]$LogDir,
|
||||
[switch]$AllowFailure
|
||||
)
|
||||
return Invoke-LoggedCommand -Name $Name -FilePath $script:ResolvedBrowsePath -Arguments $Arguments -LogDir $LogDir -AllowFailure:$AllowFailure
|
||||
}
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
if (-not (Test-Path "$HOME\.Codex\skills\gstack\bin")) {
|
||||
throw "GSTACK_MISSING: gstack is required for AI-assisted work in this repo."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) {
|
||||
$ArtifactRoot = Join-Path $repoRoot "project-docs\qa-artifacts\phase15-product-smoke"
|
||||
}
|
||||
$timestamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss"
|
||||
$artifactDir = Join-Path $ArtifactRoot $timestamp
|
||||
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
|
||||
|
||||
$summaryJsonPath = Join-Path $artifactDir "phase15-product-smoke-summary.json"
|
||||
$summaryMdPath = Join-Path $artifactDir "phase15-product-smoke-summary.md"
|
||||
$checks = New-Object System.Collections.Generic.List[object]
|
||||
$apiBase = $BackendBaseUrl.TrimEnd("/") + "/api/v1"
|
||||
|
||||
Add-Check -Checks $checks -Key "gstack" -Label "gstack prerequisite" -Status "pass" -Detail "GSTACK_OK"
|
||||
|
||||
$health = Invoke-HttpProbe -Url ($BackendBaseUrl.TrimEnd("/") + "/health")
|
||||
Add-Check -Checks $checks -Key "backend_health" -Label "backend health" -Status $(if ($health.ok) { "pass" } else { "fail" }) -Detail "GET /health status=$($health.status_code)" -Meta @{ url = ($BackendBaseUrl.TrimEnd("/") + "/health") }
|
||||
|
||||
$readiness = Invoke-HttpProbe -Url ($apiBase + "/system/readiness")
|
||||
Add-Check -Checks $checks -Key "backend_readiness" -Label "backend readiness" -Status $(if ($readiness.ok) { "pass" } else { "fail" }) -Detail "GET /api/v1/system/readiness status=$($readiness.status_code)" -Meta @{ url = ($apiBase + "/system/readiness") }
|
||||
|
||||
try {
|
||||
$smokeAccount = New-SmokeAccount -ApiBase $apiBase -RunStamp $timestamp -ArtifactDir $artifactDir
|
||||
Add-Check -Checks $checks -Key "smoke_account" -Label "smoke account" -Status "pass" -Detail "registered and logged in smoke account" -Meta @{ username = $smokeAccount.username; user_id = $smokeAccount.user.id; role = $smokeAccount.user.role }
|
||||
} catch {
|
||||
Add-Check -Checks $checks -Key "smoke_account" -Label "smoke account" -Status "fail" -Detail $_.Exception.Message
|
||||
$smokeAccount = $null
|
||||
}
|
||||
|
||||
$runtimeSmokeScriptPath = Join-Path $artifactDir "phase15_product_smoke_runtime.py"
|
||||
$runtimeSmokeScript = @'
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from app.api.chat import _detect_business_auxiliary_prompt_key, _resolve_chat_prompt_runtime
|
||||
from app.db import SessionLocal
|
||||
from app.services.contract_generation_service import ContractGenerationService, ExportFormat
|
||||
from app.services.legal_document_element_schema_service import LegalDocumentElementSchemaService
|
||||
from app.services.legal_document_type_service import LegalDocumentTypeService
|
||||
from app.services.prompt_runtime_service import PromptRuntimeService
|
||||
from app.services.template_service import TemplateService
|
||||
|
||||
|
||||
GENERATION_CASES = [
|
||||
{"document_type_code": "investment_agreement", "expected_prompt_key": "investment_agreement_default"},
|
||||
{"document_type_code": "valuation_adjustment_agreement", "expected_prompt_key": "valuation_adjustment_agreement_default"},
|
||||
{"document_type_code": "shareholder_resolution", "expected_prompt_key": "shareholder_resolution_default"},
|
||||
{"document_type_code": "power_of_attorney", "expected_prompt_key": "power_of_attorney_default"},
|
||||
]
|
||||
|
||||
BUSINESS_AUXILIARY_CASES = [
|
||||
{"case_key": "obligation_extraction", "message": "performance obligation extraction", "expected_prompt_key": "obligation_extraction_default"},
|
||||
{"case_key": "filing_materials_checklist", "message": "filing materials checklist", "expected_prompt_key": "filing_materials_checklist_default"},
|
||||
{"case_key": "negotiation_strategy", "message": "negotiation strategy", "expected_prompt_key": "negotiation_strategy_default"},
|
||||
{"case_key": "legal_opinion_boundary_fallback", "message": "legal opinion boundary fallback", "expected_prompt_key": "legal_opinion_boundary_fallback_default"},
|
||||
]
|
||||
|
||||
|
||||
class SmokeReviewService:
|
||||
def create_review_task(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
return {"id": f"review-smoke-{uuid4().hex[:10]}", "status": "completed"}
|
||||
|
||||
def get_review_detail(self, current_user: Dict[str, Any], review_id: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": review_id,
|
||||
"contract_type": "product_smoke",
|
||||
"review_as": "party_a",
|
||||
"status": "completed",
|
||||
"current_stage": "report",
|
||||
"current_stage_label": "Report generated",
|
||||
"status_detail": "smoke stub completed",
|
||||
"workflow_state": {"risk_level": "low"},
|
||||
"ai_trace": {"status": "completed", "metadata": {"smoke": True}},
|
||||
"source_document": {"file_name": "phase15-product-smoke.html", "parse_status": "completed"},
|
||||
}
|
||||
|
||||
def get_review_result(self, current_user: Dict[str, Any], review_id: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"basic_info": {"contract_type": "product_smoke"},
|
||||
"risk_summary": {"overall": "low"},
|
||||
"risk_items": [],
|
||||
"review_opinion": "smoke ok",
|
||||
"recommendation": "approved",
|
||||
}
|
||||
|
||||
def get_review_report(self, current_user: Dict[str, Any], review_id: str) -> Dict[str, Any]:
|
||||
return {"file_name": f"review-report-{review_id}.pdf", "download_url": f"/api/v1/reviews/{review_id}/report/download"}
|
||||
|
||||
|
||||
def selected_runtime(runtime: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
runtime = runtime or {}
|
||||
keys = [
|
||||
"available",
|
||||
"prompt_asset_id",
|
||||
"prompt_asset_version_id",
|
||||
"prompt_key",
|
||||
"prompt_type",
|
||||
"version_label",
|
||||
"prompt_version",
|
||||
"template_key",
|
||||
"target_scene",
|
||||
"source",
|
||||
"match_strategy",
|
||||
"evaluation_status",
|
||||
"evaluation_score",
|
||||
]
|
||||
return {key: runtime.get(key) for key in keys if key in runtime}
|
||||
|
||||
|
||||
def sample_value(field_name: str) -> str:
|
||||
lowered = field_name.lower()
|
||||
if "amount" in lowered or "price" in lowered or "capital" in lowered:
|
||||
return "1000000"
|
||||
if "date" in lowered or "time" in lowered:
|
||||
return "2026-05-24"
|
||||
if "term" in lowered or "period" in lowered:
|
||||
return "12 months"
|
||||
if "party_a" in lowered or "investor" in lowered or "principal" in lowered:
|
||||
return "Alpha Smoke Co., Ltd."
|
||||
if "party_b" in lowered or "target" in lowered or "agent" in lowered:
|
||||
return "Beta Smoke Co., Ltd."
|
||||
return f"Smoke value for {field_name}"
|
||||
|
||||
|
||||
def build_variable_values(template: Dict[str, Any]) -> Dict[str, str]:
|
||||
values: Dict[str, str] = {}
|
||||
for item in template.get("variables") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name") or "").strip()
|
||||
if name:
|
||||
values[name] = sample_value(name)
|
||||
return values
|
||||
|
||||
|
||||
def add_case_result(results: List[Dict[str, Any]], payload: Dict[str, Any]) -> None:
|
||||
results.append(payload)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output-dir", required=True)
|
||||
parser.add_argument("--tenant-id", required=True)
|
||||
parser.add_argument("--user-id", required=True)
|
||||
parser.add_argument("--display-name", default="Codex Product Smoke")
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
current_user = {
|
||||
"id": args.user_id,
|
||||
"tenant_id": args.tenant_id,
|
||||
"display_name": args.display_name,
|
||||
"role": "business",
|
||||
}
|
||||
|
||||
generation_results: List[Dict[str, Any]] = []
|
||||
business_results: List[Dict[str, Any]] = []
|
||||
document_type_seed_result: Dict[str, Any] = {"created": [], "existing": []}
|
||||
element_schema_seed_result: Dict[str, Any] = {"created": [], "existing": [], "skipped": []}
|
||||
template_seed_result: Dict[str, Any] = {"created": [], "existing": []}
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
admin_user = dict(current_user)
|
||||
admin_user["role"] = "admin"
|
||||
document_type_service = LegalDocumentTypeService(db)
|
||||
element_schema_service = LegalDocumentElementSchemaService(db)
|
||||
template_service = TemplateService(db)
|
||||
document_type_seed_result = document_type_service.seed_default_document_types(admin_user)
|
||||
element_schema_seed_result = element_schema_service.seed_default_element_schemas(admin_user)
|
||||
template_seed_result = template_service.seed_default_templates(admin_user)
|
||||
|
||||
generation_service = ContractGenerationService(db)
|
||||
generation_service.review_service = SmokeReviewService()
|
||||
|
||||
for case in GENERATION_CASES:
|
||||
document_type_code = case["document_type_code"]
|
||||
expected_key = case["expected_prompt_key"]
|
||||
try:
|
||||
templates = generation_service.list_available_templates(
|
||||
current_user=current_user,
|
||||
document_type_code=document_type_code,
|
||||
)
|
||||
if not templates:
|
||||
raise RuntimeError(f"no enabled template for {document_type_code}")
|
||||
template = templates[0]
|
||||
values = build_variable_values(template)
|
||||
if not values and template.get("variables"):
|
||||
raise RuntimeError(f"no variable values built for {document_type_code}")
|
||||
generated = generation_service.generate_contract(
|
||||
current_user=current_user,
|
||||
template_id=template["id"],
|
||||
variable_values=values,
|
||||
export_format=ExportFormat.HTML,
|
||||
options={"smoke": "phase15_product_smoke"},
|
||||
document_type_code=document_type_code,
|
||||
)
|
||||
runtime = (
|
||||
generated.get("metadata", {})
|
||||
.get("prompt_runtime", {})
|
||||
.get("contract_generation", {})
|
||||
)
|
||||
passed = (
|
||||
generated.get("document_type_code") == document_type_code
|
||||
and runtime.get("available") is True
|
||||
and runtime.get("source") == "published_prompt_asset"
|
||||
and runtime.get("prompt_key") == expected_key
|
||||
and runtime.get("match_strategy") == "preferred_key"
|
||||
and bool(generated.get("generated_html"))
|
||||
)
|
||||
add_case_result(
|
||||
generation_results,
|
||||
{
|
||||
"case_key": document_type_code,
|
||||
"status": "pass" if passed else "fail",
|
||||
"template_id": template.get("id"),
|
||||
"template_name": template.get("name"),
|
||||
"generated_contract_id": generated.get("id"),
|
||||
"expected_prompt_key": expected_key,
|
||||
"runtime": selected_runtime(runtime),
|
||||
"variable_count": len(values),
|
||||
"file_name": generated.get("file_name"),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
add_case_result(
|
||||
generation_results,
|
||||
{
|
||||
"case_key": document_type_code,
|
||||
"status": "fail",
|
||||
"expected_prompt_key": expected_key,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
prompt_runtime_service = PromptRuntimeService(db)
|
||||
for case in BUSINESS_AUXILIARY_CASES:
|
||||
expected_key = case["expected_prompt_key"]
|
||||
try:
|
||||
detected_key = _detect_business_auxiliary_prompt_key(case["message"], None)
|
||||
runtime = _resolve_chat_prompt_runtime(current_user, None, prompt_runtime_service, case["message"])
|
||||
if runtime.get("available"):
|
||||
prompt_runtime_service.record_run_link(
|
||||
current_user,
|
||||
runtime,
|
||||
run_type="business_auxiliary",
|
||||
run_id=f"phase15-product-smoke-{case['case_key']}",
|
||||
resource_type="conversation",
|
||||
resource_id=f"phase15-product-smoke-{case['case_key']}",
|
||||
trace={"message": case["message"], "detected_key": detected_key},
|
||||
)
|
||||
passed = (
|
||||
detected_key == expected_key
|
||||
and runtime.get("available") is True
|
||||
and runtime.get("source") == "published_prompt_asset"
|
||||
and runtime.get("prompt_key") == expected_key
|
||||
and runtime.get("match_strategy") == "preferred_key"
|
||||
)
|
||||
add_case_result(
|
||||
business_results,
|
||||
{
|
||||
"case_key": case["case_key"],
|
||||
"status": "pass" if passed else "fail",
|
||||
"message": case["message"],
|
||||
"detected_prompt_key": detected_key,
|
||||
"expected_prompt_key": expected_key,
|
||||
"runtime": selected_runtime(runtime),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
add_case_result(
|
||||
business_results,
|
||||
{
|
||||
"case_key": case["case_key"],
|
||||
"status": "fail",
|
||||
"message": case["message"],
|
||||
"expected_prompt_key": expected_key,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
generation_passed = sum(1 for item in generation_results if item.get("status") == "pass")
|
||||
business_passed = sum(1 for item in business_results if item.get("status") == "pass")
|
||||
status = "PASS" if generation_passed == len(GENERATION_CASES) and business_passed == len(BUSINESS_AUXILIARY_CASES) else "FAIL"
|
||||
summary = {
|
||||
"status": status,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"metrics": {
|
||||
"document_type_seed_created": len(document_type_seed_result.get("created") or []),
|
||||
"document_type_seed_existing": len(document_type_seed_result.get("existing") or []),
|
||||
"element_schema_seed_created": len(element_schema_seed_result.get("created") or []),
|
||||
"element_schema_seed_existing": len(element_schema_seed_result.get("existing") or []),
|
||||
"element_schema_seed_skipped": len(element_schema_seed_result.get("skipped") or []),
|
||||
"template_seed_created": len(template_seed_result.get("created") or []),
|
||||
"template_seed_existing": len(template_seed_result.get("existing") or []),
|
||||
"generation_cases_total": len(GENERATION_CASES),
|
||||
"generation_cases_passed": generation_passed,
|
||||
"business_auxiliary_cases_total": len(BUSINESS_AUXILIARY_CASES),
|
||||
"business_auxiliary_cases_passed": business_passed,
|
||||
},
|
||||
"generation_results": generation_results,
|
||||
"business_auxiliary_results": business_results,
|
||||
}
|
||||
output_path = os.path.join(args.output_dir, "phase15-product-smoke-runtime.json")
|
||||
with open(output_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(summary, handle, ensure_ascii=False, indent=2)
|
||||
print(json.dumps({"status": status, "output_path": output_path}, ensure_ascii=False))
|
||||
return 0 if status == "PASS" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
'@
|
||||
$runtimeSmokeScript | Set-Content -Encoding UTF8 -Path $runtimeSmokeScriptPath
|
||||
|
||||
$authMode = ""
|
||||
$sshpassPath = $null
|
||||
if ($UseSshpass -or -not [string]::IsNullOrWhiteSpace($env:SSHPASS)) {
|
||||
$sshpassPath = Resolve-SshpassPath
|
||||
if ([string]::IsNullOrWhiteSpace($sshpassPath)) {
|
||||
throw "sshpass.exe not found. Install sshpass or use SSH key auth."
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($env:SSHPASS)) {
|
||||
throw "SSHPASS environment variable is empty."
|
||||
}
|
||||
$authMode = "sshpass"
|
||||
} elseif (Test-Path $SshKeyPath) {
|
||||
$authMode = "key"
|
||||
} else {
|
||||
throw "SSH key not found and SSHPASS is not configured."
|
||||
}
|
||||
|
||||
$target = "${RemoteUser}@${RemoteHost}"
|
||||
$remoteWorkDir = "/tmp/ai-contract-phase15-product-smoke-$timestamp"
|
||||
$containerScript = "$remoteWorkDir/phase15_product_smoke_runtime.py"
|
||||
$containerOutput = "$remoteWorkDir/output"
|
||||
|
||||
if ($authMode -eq "sshpass") {
|
||||
$sshFile = $sshpassPath
|
||||
$scpFile = $sshpassPath
|
||||
$sshPrefix = @("-e", "ssh")
|
||||
$scpPrefix = @("-e", "scp")
|
||||
} else {
|
||||
$sshFile = "ssh"
|
||||
$scpFile = "scp"
|
||||
$sshPrefix = @("-i", $SshKeyPath)
|
||||
$scpPrefix = @("-i", $SshKeyPath)
|
||||
}
|
||||
|
||||
$sshCommon = @("-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10")
|
||||
$scpCommon = @("-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10")
|
||||
if ($authMode -eq "key") {
|
||||
$sshCommon = @("-o", "BatchMode=yes") + $sshCommon
|
||||
$scpCommon = @("-o", "BatchMode=yes") + $scpCommon
|
||||
}
|
||||
|
||||
$runtimeStatus = "fail"
|
||||
try {
|
||||
Invoke-LoggedCommand -Name "remote-mkdir" -FilePath $sshFile -Arguments ($sshPrefix + $sshCommon + @($target, "rm -rf $(ConvertTo-ShellSingleQuote $remoteWorkDir) && mkdir -p $(ConvertTo-ShellSingleQuote $remoteWorkDir)")) -LogDir $artifactDir | Out-Null
|
||||
Invoke-LoggedCommand -Name "scp-runtime-smoke-script" -FilePath $scpFile -Arguments ($scpPrefix + $scpCommon + @($runtimeSmokeScriptPath, "${target}:$remoteWorkDir/phase15_product_smoke_runtime.py")) -LogDir $artifactDir | Out-Null
|
||||
|
||||
$remoteUserId = if ($null -ne $smokeAccount -and $null -ne $smokeAccount.user) { [string]$smokeAccount.user.id } else { "codex-product-smoke" }
|
||||
$remoteDisplayName = "CodexProductSmoke"
|
||||
$containerRunCommand = "cd /app && PYTHONPATH=/app python $(ConvertTo-ShellSingleQuote $containerScript) --output-dir $(ConvertTo-ShellSingleQuote $containerOutput) --tenant-id $(ConvertTo-ShellSingleQuote $TenantId) --user-id $(ConvertTo-ShellSingleQuote $remoteUserId) --display-name $(ConvertTo-ShellSingleQuote $remoteDisplayName)"
|
||||
$remoteCommandParts = @(
|
||||
"set +e",
|
||||
"docker exec $BackendContainer sh -lc $(ConvertTo-ShellSingleQuote "rm -rf $remoteWorkDir && mkdir -p $containerOutput")",
|
||||
"prep_exit=`$?",
|
||||
"if [ `$prep_exit -ne 0 ]; then exit `$prep_exit; fi",
|
||||
"docker cp $remoteWorkDir/phase15_product_smoke_runtime.py ${BackendContainer}:$containerScript",
|
||||
"copy_in_exit=`$?",
|
||||
"if [ `$copy_in_exit -ne 0 ]; then exit `$copy_in_exit; fi",
|
||||
"docker exec $BackendContainer sh -lc $(ConvertTo-ShellSingleQuote $containerRunCommand)",
|
||||
"smoke_exit=`$?",
|
||||
"docker cp ${BackendContainer}:$containerOutput $remoteWorkDir/output >/dev/null 2>&1 || true",
|
||||
"exit `$smoke_exit"
|
||||
)
|
||||
$remoteCommand = $remoteCommandParts -join " ; "
|
||||
$remoteRun = Invoke-LoggedCommand -Name "remote-runtime-smoke" -FilePath $sshFile -Arguments ($sshPrefix + $sshCommon + @($target, $remoteCommand)) -LogDir $artifactDir -AllowFailure
|
||||
Invoke-LoggedCommand -Name "scp-runtime-smoke-output" -FilePath $scpFile -Arguments ($scpPrefix + $scpCommon + @("-r", "${target}:$remoteWorkDir/output/.", $artifactDir)) -LogDir $artifactDir | Out-Null
|
||||
|
||||
$runtimeSummaryPath = Join-Path $artifactDir "phase15-product-smoke-runtime.json"
|
||||
$runtimeSummary = Read-JsonFile -Path $runtimeSummaryPath
|
||||
$runtimeStatus = [string]$runtimeSummary.status
|
||||
if ($remoteRun.exit_code -ne 0 -and $runtimeStatus -eq "PASS") {
|
||||
$runtimeStatus = "FAIL"
|
||||
}
|
||||
Add-Check -Checks $checks -Key "runtime_product_smoke" -Label "runtime product smoke" -Status $(if ($runtimeStatus -eq "PASS") { "pass" } else { "fail" }) -Detail "generation=$($runtimeSummary.metrics.generation_cases_passed)/$($runtimeSummary.metrics.generation_cases_total); business_auxiliary=$($runtimeSummary.metrics.business_auxiliary_cases_passed)/$($runtimeSummary.metrics.business_auxiliary_cases_total)" -Meta @{ artifact = $runtimeSummaryPath }
|
||||
} catch {
|
||||
Add-Check -Checks $checks -Key "runtime_product_smoke" -Label "runtime product smoke" -Status "fail" -Detail $_.Exception.Message
|
||||
$runtimeSummary = $null
|
||||
}
|
||||
|
||||
$script:ResolvedBrowsePath = Resolve-BrowsePath -RequestedPath $BrowsePath
|
||||
$frontendRoutes = @(
|
||||
"investment_agreement",
|
||||
"valuation_adjustment_agreement",
|
||||
"shareholder_resolution",
|
||||
"power_of_attorney"
|
||||
)
|
||||
$frontendRouteResults = New-Object System.Collections.Generic.List[object]
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($script:ResolvedBrowsePath)) {
|
||||
Add-Check -Checks $checks -Key "gstack_browse" -Label "gstack browse" -Status "fail" -Detail "browse.exe not found"
|
||||
} elseif ($null -eq $smokeAccount -or [string]::IsNullOrWhiteSpace([string]$smokeAccount.access_token)) {
|
||||
Add-Check -Checks $checks -Key "gstack_browse" -Label "gstack browse" -Status "fail" -Detail "smoke account token unavailable"
|
||||
} else {
|
||||
try {
|
||||
Add-Check -Checks $checks -Key "gstack_browse" -Label "gstack browse" -Status "pass" -Detail "browse executable found" -Meta @{ path = $script:ResolvedBrowsePath }
|
||||
Invoke-BrowseCommand -Name "browse-status" -Arguments @("status") -LogDir $artifactDir -AllowFailure | Out-Null
|
||||
Invoke-BrowseCommand -Name "browse-login-page" -Arguments @("goto", ($FrontendBaseUrl.TrimEnd("/") + "/login")) -LogDir $artifactDir -AllowFailure | Out-Null
|
||||
|
||||
$authStore = [ordered]@{
|
||||
state = [ordered]@{
|
||||
accessToken = $smokeAccount.access_token
|
||||
currentUser = $smokeAccount.user
|
||||
}
|
||||
version = 0
|
||||
}
|
||||
$authStoreString = $authStore | ConvertTo-Json -Compress -Depth 50
|
||||
Invoke-BrowseCommand -Name "browse-set-access-token" -Arguments @("storage", "set", "access_token", ([string]$smokeAccount.access_token)) -LogDir $artifactDir | Out-Null
|
||||
Invoke-BrowseCommand -Name "browse-set-auth-store" -Arguments @("storage", "set", "auth-store", ([string]$authStoreString)) -LogDir $artifactDir | Out-Null
|
||||
Invoke-BrowseCommand -Name "browse-clear-console-before-routes" -Arguments @("console", "--clear") -LogDir $artifactDir -AllowFailure | Out-Null
|
||||
Invoke-BrowseCommand -Name "browse-clear-network-before-routes" -Arguments @("network", "--clear") -LogDir $artifactDir -AllowFailure | Out-Null
|
||||
|
||||
foreach ($route in $frontendRoutes) {
|
||||
$routeUrl = $FrontendBaseUrl.TrimEnd("/") + "/documents/new/$route"
|
||||
$safeRoute = $route -replace "[^0-9A-Za-z_-]", "_"
|
||||
Invoke-BrowseCommand -Name "browse-clear-console-$safeRoute" -Arguments @("console", "--clear") -LogDir $artifactDir -AllowFailure | Out-Null
|
||||
Invoke-BrowseCommand -Name "browse-clear-network-$safeRoute" -Arguments @("network", "--clear") -LogDir $artifactDir -AllowFailure | Out-Null
|
||||
$goto = Invoke-BrowseCommand -Name "browse-goto-$safeRoute" -Arguments @("goto", $routeUrl) -LogDir $artifactDir -AllowFailure
|
||||
Start-Sleep -Milliseconds 800
|
||||
$finalUrl = (Invoke-BrowseCommand -Name "browse-url-$safeRoute" -Arguments @("url") -LogDir $artifactDir -AllowFailure).output.Trim()
|
||||
$textOutput = (Invoke-BrowseCommand -Name "browse-text-$safeRoute" -Arguments @("text") -LogDir $artifactDir -AllowFailure).output
|
||||
$consoleOutput = (Invoke-BrowseCommand -Name "browse-console-$safeRoute" -Arguments @("console", "--errors") -LogDir $artifactDir -AllowFailure).output
|
||||
$routePassed = (
|
||||
($finalUrl -eq $routeUrl) -and
|
||||
($textOutput -notmatch "Secure Access") -and
|
||||
($textOutput -notmatch "Account Access") -and
|
||||
($textOutput -notmatch "legal document type not found") -and
|
||||
($textOutput -notmatch "当前文书类型未查询到启用模板") -and
|
||||
($consoleOutput -match "\(no console errors\)")
|
||||
)
|
||||
$frontendRouteResults.Add([ordered]@{
|
||||
route = $route
|
||||
status = if ($routePassed) { "pass" } else { "fail" }
|
||||
requested_url = $routeUrl
|
||||
final_url = $finalUrl
|
||||
goto_exit_code = $goto.exit_code
|
||||
console_clean = ($consoleOutput -match "\(no console errors\)")
|
||||
}) | Out-Null
|
||||
}
|
||||
|
||||
$frontendPassed = @($frontendRouteResults | Where-Object { $_.status -eq "pass" }).Count
|
||||
Add-Check -Checks $checks -Key "frontend_document_routes" -Label "frontend document routes" -Status $(if ($frontendPassed -eq $frontendRoutes.Count) { "pass" } else { "fail" }) -Detail "routes=$frontendPassed/$($frontendRoutes.Count)" -Meta @{ browse_path = $script:ResolvedBrowsePath }
|
||||
} catch {
|
||||
Add-Check -Checks $checks -Key "frontend_document_routes" -Label "frontend document routes" -Status "fail" -Detail $_.Exception.Message
|
||||
}
|
||||
}
|
||||
|
||||
$requiredFailures = @($checks | Where-Object { $_.required -and $_.status -eq "fail" })
|
||||
$status = if ($requiredFailures.Count -eq 0) { "PASS" } else { "FAIL" }
|
||||
$frontendPassedCount = @($frontendRouteResults | Where-Object { $_.status -eq "pass" }).Count
|
||||
|
||||
$summary = [ordered]@{
|
||||
status = $status
|
||||
generated_at = (Get-Date).ToUniversalTime().ToString("o")
|
||||
artifact_dir = $artifactDir
|
||||
endpoints = [ordered]@{
|
||||
backend_base_url = $BackendBaseUrl
|
||||
frontend_base_url = $FrontendBaseUrl
|
||||
backend_container = $BackendContainer
|
||||
}
|
||||
metrics = [ordered]@{
|
||||
generation_cases_total = if ($null -ne $runtimeSummary) { [int]$runtimeSummary.metrics.generation_cases_total } else { 4 }
|
||||
generation_cases_passed = if ($null -ne $runtimeSummary) { [int]$runtimeSummary.metrics.generation_cases_passed } else { 0 }
|
||||
business_auxiliary_cases_total = if ($null -ne $runtimeSummary) { [int]$runtimeSummary.metrics.business_auxiliary_cases_total } else { 4 }
|
||||
business_auxiliary_cases_passed = if ($null -ne $runtimeSummary) { [int]$runtimeSummary.metrics.business_auxiliary_cases_passed } else { 0 }
|
||||
frontend_routes_total = $frontendRoutes.Count
|
||||
frontend_routes_passed = $frontendPassedCount
|
||||
required_failures = $requiredFailures.Count
|
||||
}
|
||||
checks = $checks
|
||||
frontend_routes = $frontendRouteResults
|
||||
runtime_artifact = if (Test-Path (Join-Path $artifactDir "phase15-product-smoke-runtime.json")) { (Join-Path $artifactDir "phase15-product-smoke-runtime.json") } else { $null }
|
||||
}
|
||||
Save-Json -Path $summaryJsonPath -Value $summary
|
||||
|
||||
$markdown = New-Object System.Collections.Generic.List[string]
|
||||
$markdown.Add("# Phase15 Product Smoke Summary") | Out-Null
|
||||
$markdown.Add("") | Out-Null
|
||||
$markdown.Add("- Status: $status") | Out-Null
|
||||
$markdown.Add("- Artifact dir: $artifactDir") | Out-Null
|
||||
$markdown.Add("- Backend: $BackendBaseUrl") | Out-Null
|
||||
$markdown.Add("- Frontend: $FrontendBaseUrl") | Out-Null
|
||||
$markdown.Add("") | Out-Null
|
||||
$markdown.Add("## Metrics") | Out-Null
|
||||
$markdown.Add("") | Out-Null
|
||||
$markdown.Add("- Generation routes: $($summary.metrics.generation_cases_passed)/$($summary.metrics.generation_cases_total)") | Out-Null
|
||||
$markdown.Add("- Business auxiliary routes: $($summary.metrics.business_auxiliary_cases_passed)/$($summary.metrics.business_auxiliary_cases_total)") | Out-Null
|
||||
$markdown.Add("- Frontend document pages: $($summary.metrics.frontend_routes_passed)/$($summary.metrics.frontend_routes_total)") | Out-Null
|
||||
$markdown.Add("- Required failures: $($summary.metrics.required_failures)") | Out-Null
|
||||
$markdown.Add("") | Out-Null
|
||||
$markdown.Add("## Checks") | Out-Null
|
||||
$markdown.Add("") | Out-Null
|
||||
foreach ($check in $checks) {
|
||||
$markdown.Add("- [$($check.status)] $($check.key): $($check.detail)") | Out-Null
|
||||
}
|
||||
$markdown | Set-Content -Encoding UTF8 -Path $summaryMdPath
|
||||
|
||||
if ($status -eq "PASS") {
|
||||
Write-Output "PHASE15_PRODUCT_SMOKE_PASS $artifactDir"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Output "PHASE15_PRODUCT_SMOKE_FAIL $artifactDir"
|
||||
exit 1
|
||||
Reference in New Issue
Block a user