250 lines
8.8 KiB
PowerShell
250 lines
8.8 KiB
PowerShell
param(
|
|
[string]$LawsBackupDir = "E:\DB_Backups\192.168.3.90_20260519_114618\file_backups\cn-laws-download_20260519_155247",
|
|
[string]$ArtifactRoot = "",
|
|
[string]$RemoteHost = "192.168.3.90",
|
|
[string]$RemoteUser = "root",
|
|
[string]$BackendContainer = "ai-contract-backend-restore",
|
|
[string]$SshKeyPath = "$HOME\.ssh\id_rsa",
|
|
[switch]$UseSshpass,
|
|
[string]$TenantId = "tenant-demo-001",
|
|
[string]$PrepareSummaryJson = "",
|
|
[string]$ImportSummaryJson = ""
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
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 Invoke-LoggedCommand {
|
|
param(
|
|
[string]$Name,
|
|
[string]$FilePath,
|
|
[string[]]$Arguments,
|
|
[string]$LogDir
|
|
)
|
|
|
|
$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) {
|
|
throw "$Name failed with exit code $exitCode. See $logPath"
|
|
}
|
|
return $outputText
|
|
}
|
|
|
|
function Get-JsonFromOutput {
|
|
param([string]$Text)
|
|
|
|
$start = $Text.IndexOf("{")
|
|
if ($start -lt 0) {
|
|
throw "Remote output did not contain a JSON object."
|
|
}
|
|
return $Text.Substring($start)
|
|
}
|
|
|
|
$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\legal-source-diff-report"
|
|
}
|
|
$timestamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss"
|
|
$artifactDir = Join-Path $ArtifactRoot $timestamp
|
|
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
|
|
|
|
if ([string]::IsNullOrWhiteSpace($PrepareSummaryJson)) {
|
|
$PrepareSummaryJson = Join-Path $repoRoot "project-docs\qa-artifacts\cn-laws-download-import\2026-05-20T09-53-00\cn_laws_prepare_summary_20260520.json"
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($ImportSummaryJson)) {
|
|
$ImportSummaryJson = Join-Path $repoRoot "project-docs\qa-artifacts\cn-laws-download-import\2026-05-20T09-53-00\cn_laws_import_summary_20260520.json"
|
|
}
|
|
|
|
foreach ($requiredPath in @($LawsBackupDir, $PrepareSummaryJson, $ImportSummaryJson)) {
|
|
if (-not (Test-Path $requiredPath)) {
|
|
throw "Required input path does not exist: $requiredPath"
|
|
}
|
|
}
|
|
|
|
$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."
|
|
}
|
|
|
|
$remoteScript = @'
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from sqlalchemy import create_engine, text
|
|
|
|
|
|
tenant_id = "__TENANT_ID__"
|
|
database_url = os.environ.get("DATABASE_URL")
|
|
if not database_url:
|
|
raise RuntimeError("DATABASE_URL is required in backend container")
|
|
|
|
engine = create_engine(database_url)
|
|
with engine.connect() as conn:
|
|
summary = dict(
|
|
conn.execute(
|
|
text(
|
|
"""
|
|
select
|
|
count(*) as legal_article_count,
|
|
count(distinct source_id) as remote_source_count,
|
|
count(distinct source_title) as remote_title_count
|
|
from legal_articles
|
|
where tenant_id = :tenant_id
|
|
"""
|
|
),
|
|
{"tenant_id": tenant_id},
|
|
).first()._mapping
|
|
)
|
|
sources = [
|
|
dict(row._mapping)
|
|
for row in conn.execute(
|
|
text(
|
|
"""
|
|
select
|
|
source_id,
|
|
min(source_title) as source_title,
|
|
min(source_type) as source_type,
|
|
min(authority) as authority,
|
|
min(effective_date) as effective_date,
|
|
min(validity_status) as validity_status,
|
|
min(region) as region,
|
|
min(official_url) as official_url,
|
|
min(source_checksum) as source_checksum,
|
|
count(distinct source_checksum) as source_checksum_count,
|
|
count(*) as article_count,
|
|
count(distinct text_checksum) as text_checksum_count,
|
|
count(*) filter (where authority is null or authority = '') as missing_authority_rows,
|
|
count(*) filter (where effective_date is null or effective_date = '') as missing_effective_date_rows,
|
|
count(*) filter (where region is null or region = '') as missing_region_rows,
|
|
count(*) filter (where tags_json is null or json_array_length(tags_json) = 0) as missing_tags_rows,
|
|
min(created_at) as first_created_at,
|
|
max(updated_at) as last_updated_at
|
|
from legal_articles
|
|
where tenant_id = :tenant_id
|
|
group by source_id
|
|
order by source_id
|
|
"""
|
|
),
|
|
{"tenant_id": tenant_id},
|
|
)
|
|
]
|
|
|
|
payload = {
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"tenant_id": tenant_id,
|
|
"legal_article_count": int(summary["legal_article_count"] or 0),
|
|
"remote_source_count": int(summary["remote_source_count"] or 0),
|
|
"remote_title_count": int(summary["remote_title_count"] or 0),
|
|
"sources": sources,
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, default=str, indent=2))
|
|
'@
|
|
$remoteScript = $remoteScript.Replace("__TENANT_ID__", $TenantId)
|
|
|
|
$localRemoteScript = Join-Path $artifactDir "remote-legal-source-snapshot.py"
|
|
$remoteSnapshotPath = Join-Path $artifactDir "remote-legal-source-snapshot.json"
|
|
$remoteScript | Set-Content -Encoding UTF8 -Path $localRemoteScript
|
|
|
|
$target = "${RemoteUser}@${RemoteHost}"
|
|
$remoteTempScript = "/tmp/legal_source_diff_remote_snapshot.py"
|
|
|
|
if ($authMode -eq "sshpass") {
|
|
Invoke-LoggedCommand -Name "scp-remote-legal-source-script" -FilePath $sshpassPath -Arguments @(
|
|
"-e",
|
|
"scp",
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "ConnectTimeout=10",
|
|
$localRemoteScript,
|
|
"${target}:$remoteTempScript"
|
|
) -LogDir $artifactDir | Out-Null
|
|
$remoteOutput = Invoke-LoggedCommand -Name "remote-legal-source-snapshot" -FilePath $sshpassPath -Arguments @(
|
|
"-e",
|
|
"ssh",
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "ConnectTimeout=10",
|
|
$target,
|
|
"docker cp $remoteTempScript ${BackendContainer}:$remoteTempScript && docker exec $BackendContainer sh -lc 'cd /app && python $remoteTempScript'"
|
|
) -LogDir $artifactDir
|
|
} else {
|
|
Invoke-LoggedCommand -Name "scp-remote-legal-source-script" -FilePath "scp" -Arguments @(
|
|
"-i", $SshKeyPath,
|
|
"-o", "BatchMode=yes",
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "ConnectTimeout=10",
|
|
$localRemoteScript,
|
|
"${target}:$remoteTempScript"
|
|
) -LogDir $artifactDir | Out-Null
|
|
$remoteOutput = Invoke-LoggedCommand -Name "remote-legal-source-snapshot" -FilePath "ssh" -Arguments @(
|
|
"-i", $SshKeyPath,
|
|
"-o", "BatchMode=yes",
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "ConnectTimeout=10",
|
|
$target,
|
|
"docker cp $remoteTempScript ${BackendContainer}:$remoteTempScript && docker exec $BackendContainer sh -lc 'cd /app && python $remoteTempScript'"
|
|
) -LogDir $artifactDir
|
|
}
|
|
|
|
Get-JsonFromOutput $remoteOutput | Set-Content -Encoding UTF8 -Path $remoteSnapshotPath
|
|
|
|
$toolPath = Join-Path $repoRoot "backend\tools\legal_source_diff_report.py"
|
|
$toolOutput = & python $toolPath `
|
|
--laws-backup-dir $LawsBackupDir `
|
|
--remote-source-json $remoteSnapshotPath `
|
|
--prepare-summary-json $PrepareSummaryJson `
|
|
--import-summary-json $ImportSummaryJson `
|
|
--tenant-id $TenantId `
|
|
--output-dir $artifactDir
|
|
|
|
$toolExitCode = if ($LASTEXITCODE -is [int]) { $LASTEXITCODE } else { 0 }
|
|
($toolOutput -join [Environment]::NewLine) | Set-Content -Encoding UTF8 -Path (Join-Path $artifactDir "legal-source-diff-report-tool.log")
|
|
if ($toolExitCode -ne 0) {
|
|
throw "legal_source_diff_report.py failed with exit code $toolExitCode."
|
|
}
|
|
|
|
Write-Output ($toolOutput -join [Environment]::NewLine)
|