From 35a76d6dbd96887761cbfd7869c601db3d18fd84 Mon Sep 17 00:00:00 2001 From: yuqei Date: Sun, 24 May 2026 18:28:13 +0800 Subject: [PATCH] Add phase15 product smoke validation --- ...µ·草与审查助手-当前工作进度保存-2026-05-11.md | 71 ++ scripts/deploy-ai-contract-restore-docker.ps1 | 58 +- scripts/run-phase15-product-smoke.ps1 | 746 ++++++++++++++++++ 3 files changed, 870 insertions(+), 5 deletions(-) create mode 100644 scripts/run-phase15-product-smoke.ps1 diff --git a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md index 07c427ac..e777199f 100644 --- a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md +++ b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md @@ -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/` 页面能在登录态下真实打开并无 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。 diff --git a/scripts/deploy-ai-contract-restore-docker.ps1 b/scripts/deploy-ai-contract-restore-docker.ps1 index 52afadb7..b2069e30 100644 --- a/scripts/deploy-ai-contract-restore-docker.ps1 +++ b/scripts/deploy-ai-contract-restore-docker.ps1 @@ -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 diff --git a/scripts/run-phase15-product-smoke.ps1 b/scripts/run-phase15-product-smoke.ps1 new file mode 100644 index 00000000..f5ec5f70 --- /dev/null +++ b/scripts/run-phase15-product-smoke.ps1 @@ -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