Add phase15 product smoke validation

This commit is contained in:
2026-05-24 18:28:13 +08:00
parent 53e5eb3bba
commit 35a76d6dbd
3 changed files with 870 additions and 5 deletions
@@ -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。
+53 -5
View File
@@ -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
+746
View File
@@ -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