W34 · 会签 20/21/22 三项落地(先立单、经授权、后动手) - 会签 20(白名单外):runtime_config_service 新增 load_collection_routes() / collection_routes() / _first_collection_name(),首次消费既有 JSON 列 collection_routes; 消费方 customer_service 走「配置优先、缺失回落代码常量」。零 DDL;该列当前全为 None ⇒ 实际走回落路径,行为与改动前一致。 - 会签 21(类 3 + 融合层):retrieval_fusion 新增 fuse_rrf() + RRF_K(排名融合,只吃名次 不吃分数 ⇒ 异质分数不可能污染判定分,best_vector_score 仍只取向量路原始 cosine); knowledge_search_service::search() 新增 literal_parallel: bool = False(默认值使行为 逐字等同现状)。工具层透传未做 —— 那需改 KnowledgeSearchInput 契约(extra="forbid"), 超出本单范围。 - 会签 22(发布配置 + bootstrap):customer_service 新增 INTENT_MARKET_QUOTE 常量 + TREND_WHITELIST_INTENT_CANDIDATES(按优先级回落)+ _trend_whitelist_intent() (运行时自检 + 自动回落,强于「仅报错」)。发布配置 customer_service:market_quote (release 260,allowed_tools=['query_fund_trend'])已写入并回读校验(9 → 10 行)。 刻意未加入 supported_intents:授权维度与判定维度解耦,不动判定分布。 - 阶段 0:customer_service_rules 新增 normalize_query() + QUERY_SYNONYMS + 等级代号大写 (纯函数;同义表只收纯书写差异,语义类同义留待金标 A/B 后逐条加;调用方默认不启用)。 W35 · 判定口径与融合口径分离(修 A-01 / C-04 / I-02 / E-04 四条) - _dual_route_output 返回值新增 vector_order(向量路原始 doc_id 顺序、去重); - 新增 _vector_decision_hits() 据此还原「判定序列」(带向量分的 basic 补位块回补首位; 无 vector_order / 空 / id 全对不上 ⇒ 返回 None 回落原分支); - _answer_from_knowledge 的 score / gap 与原文直返的 best 改从向量路原始序列取。 语义边界(刻意):_evidence_pack / _exit_clarify / _answer_from_evidence 仍吃融合序列 —— 融合的收益只留在「给哪些块、什么顺序」,符合三层分数分离约束。 W36 · 选块口径归一(收口最后一条 E-01) - 新增 _pack_order():order 命中的块排前,其余按原相对顺序追加在后; - _evidence_pack 新增 order= 参数,三处遍历 hits → ordered,top 由 hits[0] → ordered[0]; - _answer_from_knowledge 传入 order=[judge 的 doc_id 序列];judge is None ⇒ None (开关关闭时逐字零改动)。order 只当排序键、不当过滤器 ⇒ 证据包成员集合不变。 演示环境与文档 - start.ps1 / demo.ps1 默认端口 8000 → 8099(与 README / docs/06,07,09,14,15,32 / tools/smoke_check.py / login_console.py 的全仓口径对齐;字节级定长替换,保住 UTF-8 BOM + CRLF,字节数不变); - portal/README.md 更正 fin_nav_history 过期口径(「0 行」→ 实测 2494 行 / 20 个产品 / nav_date 覆盖 2026-03-18—2026-09-13)。 测试(新增 3 个文件、补强 2 个) - 新增 tests/unit/service/test_decision_scope_w35.py(10 条)、 tests/unit/service/test_evidence_pack_order_w36.py(13 条)、 tests/unit/service/test_customer_service_trend_chart_inv8.py(INV-8 字面级判定, 纳入 pytest 门禁,此前只在 jsdom 脚本里覆盖); - 补强 tests/unit/core/test_customer_service_rules.py(normalize_query 7 条)与 tests/integration/test_customer_service_trend_chart_persistence.py(图内每个数字 都必须在答复正文出现过,判定口径与 INV-8 单测一致)。 验证 - 全量 pytest:2642 passed / 3 skipped / 0 failed(基线 2629 + 新增 13); - 55 条金标真实链路 A/B 四组:off / norm / dual / on 均 55/55 = 100% (改前 dual 92.7%、on 90.9%);M-4 事实正确率恒 100%、M-7—M-10 全 0; - 红线四条守住:融合/精排层仍不持 Milvus 客户端(INV-1)、阈值一字未动、零 DDL; - 三个实验开关 CS_DUAL_ROUTE / CS_RERANK / CS_QUERY_NORM 仍默认关闭。
331 lines
16 KiB
PowerShell
331 lines
16 KiB
PowerShell
<#
|
||
.SYNOPSIS
|
||
启动金融 Agent 平台:依赖检查 → API → Agent Worker。
|
||
|
||
.NOTES
|
||
⚠️ 本文件必须保存为 **UTF-8 with BOM**。
|
||
Windows PowerShell 5.1 在缺少 BOM 时按系统 ANSI 解析脚本(简体中文下是 GBK),
|
||
中文注释与输出会变成乱码并直接抛语法错误(实测报 `Unexpected token '[璀﹀憡]'`)。
|
||
用编辑器改完本文件后,务必另存为「UTF-8 带 BOM」。
|
||
|
||
.DESCRIPTION
|
||
平台需要**两个进程**才能完整工作,本脚本把它们一起起在独立窗口里:
|
||
|
||
· API —— 所有 HTTP 接口与前端页面(/portal/)
|
||
· Worker —— 消费队列:Agent 对话、知识向量同步、记忆抽取、风控扫描
|
||
|
||
**没有 Worker 的后果**(这也是最容易踩的坑):
|
||
· 客服对话会一直停在 queued,前端显示"客服繁忙/超时";
|
||
· 新灌的知识写不进 Milvus,客服照旧答不上,且**没有任何报错**。
|
||
|
||
.PARAMETER Port
|
||
API 监听端口,默认 8099。
|
||
|
||
.PARAMETER SkipChecks
|
||
跳过依赖端口检查(MySQL / Redis / Milvus)。
|
||
|
||
.PARAMETER ApiOnly
|
||
只起 API,不起 Worker(仅在明确不需要 Agent 能力时使用)。
|
||
|
||
.PARAMETER SkipPriceSync
|
||
跳过启动时的行情刷新。仅在外部数据源不可用、或不想联网时使用。
|
||
|
||
.PARAMETER NoBrowser
|
||
启动后不自动打开浏览器。
|
||
|
||
.EXAMPLE
|
||
powershell -ExecutionPolicy Bypass -File start.ps1
|
||
powershell -ExecutionPolicy Bypass -File start.ps1 -Port 8100
|
||
|
||
.NOTES
|
||
本脚本是**幂等**的:重复执行不会起出第二个 API/Worker
|
||
(端口被占用、或已存在 app.worker 进程时会跳过),可以放心反复双击桌面的启动 .bat。
|
||
#>
|
||
param(
|
||
[int]$Port = 8099,
|
||
[switch]$SkipChecks,
|
||
[switch]$ApiOnly,
|
||
[switch]$SkipPriceSync,
|
||
[switch]$NoBrowser
|
||
)
|
||
|
||
$ErrorActionPreference = "Stop"
|
||
$Root = $PSScriptRoot
|
||
Set-Location $Root
|
||
|
||
Write-Host "=== 金融 Agent 平台 · 启动 ===" -ForegroundColor Cyan
|
||
Write-Host "工作目录:$Root"
|
||
|
||
# ---------------------------------------------------------------- 小工具
|
||
# 用 .NET 的 TcpClient 探端口:比 Test-NetConnection 快一个量级
|
||
# (后者每个端口要 1~5 秒,双击启动时全花在等待上),且不依赖 WinRM 相关服务。
|
||
function Test-PortOpen {
|
||
param([string]$TargetHost, [int]$TargetPort, [int]$TimeoutMs = 1500)
|
||
$client = New-Object System.Net.Sockets.TcpClient
|
||
try {
|
||
$async = $client.BeginConnect($TargetHost, $TargetPort, $null, $null)
|
||
if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) { return $false }
|
||
$client.EndConnect($async)
|
||
return $true
|
||
} catch {
|
||
return $false
|
||
} finally {
|
||
$client.Close()
|
||
}
|
||
}
|
||
|
||
# Worker 没有监听端口,只能按命令行匹配进程。
|
||
function Test-WorkerRunning {
|
||
try {
|
||
$procs = Get-CimInstance Win32_Process -Filter "Name like '%python%'" -ErrorAction SilentlyContinue
|
||
return @($procs | Where-Object { $_.CommandLine -like '*app.worker*' }).Count -gt 0
|
||
} catch {
|
||
return $false
|
||
}
|
||
}
|
||
|
||
# Milvus 跑在 Docker 里,而 **Docker Desktop 不会常驻**:它没起来时 Milvus 必然不可达,
|
||
# 后果是知识检索**静默降级**(客服答不上知识题、新灌的知识进不了向量库,且没有任何报错)。
|
||
# 所以这里尝试把 Docker Desktop 拉起来,等 Milvus 端口出现为止。
|
||
function Start-DockerIfPossible {
|
||
$candidates = @(
|
||
(Join-Path $env:ProgramFiles "Docker\Docker\Docker Desktop.exe"),
|
||
(Join-Path ${env:ProgramFiles(x86)} "Docker\Docker\Docker Desktop.exe"),
|
||
(Join-Path $env:LOCALAPPDATA "Docker\Docker Desktop.exe")
|
||
) | Where-Object { $_ -and (Test-Path $_) }
|
||
|
||
if (-not $candidates) {
|
||
Write-Host " [提示] 没找到 Docker Desktop:Milvus 无法自动拉起" -ForegroundColor DarkGray
|
||
return $false
|
||
}
|
||
|
||
Write-Host " [启动] Docker Desktop(Milvus 依赖它)…" -ForegroundColor Yellow
|
||
Start-Process ($candidates | Select-Object -First 1) | Out-Null
|
||
|
||
# 冷启动通常 30~60 秒。最多等 60 秒,每 5 秒报告一次,避免看起来像卡死。
|
||
for ($i = 1; $i -le 12; $i++) {
|
||
Start-Sleep -Seconds 5
|
||
if (Test-PortOpen "127.0.0.1" 19530) {
|
||
Write-Host (" [OK] Milvus 已就绪(等待 {0} 秒)" -f ($i * 5)) -ForegroundColor Green
|
||
return $true
|
||
}
|
||
Write-Host (" 等待 Milvus… 已等 {0} 秒" -f ($i * 5)) -ForegroundColor DarkGray
|
||
}
|
||
Write-Host " [警告] 等了 60 秒 Milvus 仍未就绪:本次启动继续,知识检索会降级" -ForegroundColor Yellow
|
||
return $false
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 解释器
|
||
# 逐个尝试:项目虚拟环境 → 常见 conda 环境 → PATH 上的 python。
|
||
# 不写死任何一个,因为各人机器的环境不同(.venv 被 gitignore,不进仓库)。
|
||
#
|
||
# ⚠️ 判定标准不是「这个 exe 能跑」,而是「这个环境能跑起本项目」。
|
||
# 这里踩过坑:原先只验证 `--version` 成功就选中,结果 PATH 上先撞到一个
|
||
# Python 3.10 环境 —— 本项目用了 `datetime.UTC`(3.11+)且依赖 `asyncmy`,
|
||
# 启动当场 ImportError,而且报错发生在**行情刷新**那一步,看上去像
|
||
# 「行情源坏了」,很难联想到是解释器选错。
|
||
# 所以这里实测两项:**版本 ≥ 3.11** + **关键依赖能导入**。
|
||
$candidates = @(
|
||
(Join-Path $Root ".venv\Scripts\python.exe"),
|
||
"D:\conda\envs\jr_py313\python.exe",
|
||
"$env:USERPROFILE\miniconda3\envs\jr_py313\python.exe",
|
||
"$env:USERPROFILE\anaconda3\envs\jr_py313\python.exe",
|
||
"python"
|
||
)
|
||
|
||
$Python = $null
|
||
foreach ($candidate in $candidates) {
|
||
if ($candidate -ne "python" -and -not (Test-Path $candidate)) { continue }
|
||
|
||
# ⚠️ 这里**不能**用 `... 2>&1 | Select-Object -First 1`。
|
||
# `Select-Object -First 1` 拿到第一个对象后会停掉上游管道,而上游是 native 命令 ——
|
||
# 等于把进程掐了,`$LASTEXITCODE` 随即变成非 0,于是**每个候选都被误判成"无法执行"**
|
||
# (实测:连装得好好的 jr_py313 也被跳过,最后报"找不到 Python 环境")。
|
||
# 先整体接住输出、再在结果上取行,才安全。
|
||
$versionOutput = ""
|
||
try {
|
||
$versionOutput = & $candidate -c "import sys;print('.'.join(map(str,sys.version_info[:3])))" 2>$null
|
||
} catch {
|
||
Write-Host " [跳过] $candidate —— 无法执行" -ForegroundColor DarkGray
|
||
continue
|
||
}
|
||
if ($LASTEXITCODE -ne 0) {
|
||
Write-Host " [跳过] $candidate —— 无法执行" -ForegroundColor DarkGray
|
||
continue
|
||
}
|
||
$version = "$versionOutput".Trim()
|
||
|
||
# 版本门槛:`app/` 里用了 datetime.UTC(3.11 起才有),3.10 必然导入失败。
|
||
$parts = $version.Split(".")
|
||
$major = [int]$parts[0]
|
||
$minor = [int]$parts[1]
|
||
if ($major -lt 3 -or ($major -eq 3 -and $minor -lt 11)) {
|
||
Write-Host " [跳过] $candidate —— Python $version,本项目要求 3.11+" -ForegroundColor DarkYellow
|
||
continue
|
||
}
|
||
|
||
# 依赖门槛:只看版本号不够,环境可能没装 asyncmy / fastapi。
|
||
# `$ErrorActionPreference = "Stop"` 下 native 命令写 stderr 会变成终止性错误,
|
||
# 所以 try 住,把报错当"这个候选不合适",而不是让整个脚本崩掉。
|
||
$probeOutput = ""
|
||
try {
|
||
$probeOutput = & $candidate -c "import fastapi, sqlalchemy, asyncmy, pydantic" 2>&1
|
||
} catch {
|
||
$probeOutput = "$_"
|
||
}
|
||
if ($LASTEXITCODE -ne 0) {
|
||
$reason = ("$probeOutput" -split "`n" |
|
||
Where-Object { $_ -match "Error" } | Select-Object -First 1)
|
||
Write-Host " [跳过] $candidate —— Python $version 但依赖不全:$reason" -ForegroundColor DarkYellow
|
||
continue
|
||
}
|
||
|
||
$Python = $candidate
|
||
Write-Host "解释器:$candidate(Python $version,依赖完整)" -ForegroundColor Green
|
||
break
|
||
}
|
||
|
||
if (-not $Python) {
|
||
Write-Host "`n[失败] 找不到能运行本项目的 Python 环境。" -ForegroundColor Red
|
||
Write-Host " 需要 3.11+ 且装齐 fastapi / sqlalchemy / asyncmy / pydantic。" -ForegroundColor Red
|
||
Write-Host " 可以先建虚拟环境:" -ForegroundColor Yellow
|
||
Write-Host " python -m venv .venv" -ForegroundColor Yellow
|
||
Write-Host " .venv\Scripts\pip install -e ." -ForegroundColor Yellow
|
||
exit 1
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 依赖检查
|
||
if (-not $SkipChecks) {
|
||
Write-Host "`n--- 依赖服务检查 ---" -ForegroundColor Cyan
|
||
|
||
# 先处理 Milvus:它跑在 Docker 里,而 Docker Desktop 不会常驻,
|
||
# 是最常见的"平台看着正常、知识检索却悄悄降级"的原因。
|
||
if (-not (Test-PortOpen "127.0.0.1" 19530)) {
|
||
Start-DockerIfPossible | Out-Null
|
||
}
|
||
|
||
$services = @(
|
||
@{ Name = "MySQL"; HostName = "127.0.0.1"; Port = 3306; Required = $true },
|
||
@{ Name = "Redis"; HostName = "127.0.0.1"; Port = 6379; Required = $false },
|
||
@{ Name = "Milvus"; HostName = "127.0.0.1"; Port = 19530; Required = $false }
|
||
)
|
||
foreach ($service in $services) {
|
||
$reachable = Test-PortOpen $service.HostName $service.Port
|
||
|
||
if ($reachable) {
|
||
Write-Host (" [OK] {0,-7} {1}:{2}" -f $service.Name, $service.HostName, $service.Port) -ForegroundColor Green
|
||
} elseif ($service.Required) {
|
||
Write-Host (" [缺失] {0,-7} {1}:{2} —— 平台起不来,请先启动它" -f $service.Name, $service.HostName, $service.Port) -ForegroundColor Red
|
||
exit 1
|
||
} else {
|
||
Write-Host (" [警告] {0,-7} {1}:{2} 不可达:相关知识检索会降级、Docker 未运行时常见" -f $service.Name, $service.HostName, $service.Port) -ForegroundColor Yellow
|
||
}
|
||
}
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 刷新行情
|
||
# ⚠️ 为什么放在启动流程里:下单要求行情快照落在 **15 分钟**有效期内
|
||
# (`app/service/trade_service.py` 的 `MAX_QUOTE_AGE`),超时后**所有委托直接 503
|
||
# 「行情已过期」**,而系统**没有任何自动刷新机制** —— 演示时讲到一半下单就会失败。
|
||
# 所以默认在启动时刷一次:15 分钟窗口从此刻重新计时。
|
||
# 同步是按产品 upsert 的幂等操作;失败只警告不阻断(仍可手动重跑)。
|
||
if (-not $SkipPriceSync) {
|
||
Write-Host "`n--- 刷新行情(下单前置,有效期 15 分钟)---" -ForegroundColor Cyan
|
||
$syncOutput = & $Python "tools\sync_market_prices.py" 2>&1
|
||
if ($LASTEXITCODE -eq 0) {
|
||
Write-Host " [OK] 行情已刷新,15 分钟内可正常下单" -ForegroundColor Green
|
||
} else {
|
||
Write-Host " [警告] 行情刷新失败(退出码 $LASTEXITCODE):下单可能返回 503" -ForegroundColor Yellow
|
||
Write-Host " 可手动重跑:$Python tools\sync_market_prices.py" -ForegroundColor Yellow
|
||
$syncOutput | Select-Object -Last 8 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
||
}
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 启动进程
|
||
# 用独立窗口起,方便分别看两边的日志(Worker 的日志是排查问题的第一现场)。
|
||
function Start-PlatformWindow {
|
||
param([string]$Title, [string[]]$Arguments)
|
||
$shell = if (Get-Command pwsh -ErrorAction SilentlyContinue) { "pwsh" } else { "powershell" }
|
||
$command = "`$host.UI.RawUI.WindowTitle = '$Title'; & '$Python' " + ($Arguments -join " ")
|
||
# 显式给 WorkingDirectory:`app.main:app` 与 `app.worker` 都是相对当前目录导入的,
|
||
# 继承错目录会直接 ModuleNotFoundError。
|
||
Start-Process -FilePath $shell -ArgumentList "-NoExit", "-Command", $command `
|
||
-WorkingDirectory $Root | Out-Null
|
||
}
|
||
|
||
Write-Host "`n--- 启动进程 ---" -ForegroundColor Cyan
|
||
|
||
# 幂等:重复双击启动脚本不应该起出第二个 API / Worker。
|
||
# API 用端口判断;Worker 没有端口,用进程命令行判断。
|
||
if (Test-PortOpen "127.0.0.1" $Port 500) {
|
||
Write-Host " [跳过] 端口 $Port 已有服务在跑(不重复启动 API)。若那不是本平台,请换端口:-Port 8100" -ForegroundColor Yellow
|
||
} else {
|
||
Start-PlatformWindow -Title "平台 API :$Port" -Arguments @(
|
||
"-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", "$Port"
|
||
)
|
||
Write-Host " [已启动] API 窗口(uvicorn,端口 $Port)" -ForegroundColor Green
|
||
}
|
||
|
||
if ($ApiOnly) {
|
||
Write-Host " [跳过] Worker(-ApiOnly):客服对话会一直 queued、新知识不会进 Milvus" -ForegroundColor Yellow
|
||
} elseif (Test-WorkerRunning) {
|
||
Write-Host " [跳过] 已有 Worker 在跑(不重复启动)" -ForegroundColor Yellow
|
||
} else {
|
||
Start-PlatformWindow -Title "平台 Agent Worker" -Arguments @("-m", "app.worker")
|
||
Write-Host " [已启动] Worker 窗口(Agent 对话 / 知识向量 / 记忆抽取)" -ForegroundColor Green
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 就绪探测
|
||
# 双击启动时最怕"窗口起了但其实没起来"。这里等 API 真正应答再报成功。
|
||
Write-Host "`n--- 等待 API 就绪 ---" -ForegroundColor Cyan
|
||
$ready = $false
|
||
for ($i = 1; $i -le 30; $i++) {
|
||
try {
|
||
$response = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/internal/health/ready" `
|
||
-TimeoutSec 2 -UseBasicParsing -ErrorAction Stop
|
||
if ($response.StatusCode -eq 200) { $ready = $true; break }
|
||
} catch { }
|
||
Start-Sleep -Seconds 1
|
||
}
|
||
if ($ready) {
|
||
Write-Host " [OK] API 已就绪(约 $i 秒)" -ForegroundColor Green
|
||
} else {
|
||
Write-Host " [警告] 等了 30 秒 API 仍未就绪:切到 API 窗口看报错(常见原因是数据库连不上)" -ForegroundColor Yellow
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 访问入口
|
||
Write-Host "`n=== 访问入口 ===" -ForegroundColor Cyan
|
||
Write-Host " 门户首页 http://127.0.0.1:$Port/portal/"
|
||
Write-Host " 访客页 http://127.0.0.1:$Port/portal/guest/home/"
|
||
Write-Host " 客户登录 http://127.0.0.1:$Port/portal/customer/login/ (cust_t / 123456)"
|
||
Write-Host " 员工登录 http://127.0.0.1:$Port/portal/employee-console/login/(见下)"
|
||
Write-Host " 接口文档 http://127.0.0.1:$Port/docs"
|
||
|
||
Write-Host "`n=== 演示账号 ===" -ForegroundColor Cyan
|
||
Write-Host " 客户 cust_t / 123456"
|
||
Write-Host " 风控专员 risk_t / 666666"
|
||
Write-Host " 管理员 admin_t / 88888888"
|
||
Write-Host " 投顾 advisor_t / abc12345"
|
||
Write-Host " 运营 offsite_t / offsite123"
|
||
|
||
Write-Host "`n提示:" -ForegroundColor Yellow
|
||
Write-Host " · 首次使用先准备数据:python tools\seed_demo_data.py"
|
||
Write-Host " · 行情有效期只有 15 分钟:演示中途下单若报 503,在新窗口重跑"
|
||
Write-Host " python tools\sync_market_prices.py (立即生效,无需重启服务)"
|
||
Write-Host " · 体检:python tools\e2e_smoke_test.py (业务链路,40 项)"
|
||
Write-Host " python tools\portal_api_check.py (接口契约,41 项)"
|
||
Write-Host " · 演示流程见 docs/44-演示流程.md"
|
||
|
||
# ---------------------------------------------------------------- 打开浏览器
|
||
# 服务已经探测就绪,此时打开才不会看到"无法访问"。用户可以从 bat 传 -NoBrowser 关掉。
|
||
if (-not $NoBrowser -and $ready) {
|
||
try {
|
||
Start-Process "http://127.0.0.1:$Port/portal/" | Out-Null
|
||
Write-Host "`n已打开浏览器:http://127.0.0.1:$Port/portal/" -ForegroundColor Green
|
||
} catch {
|
||
Write-Host "`n(自动打开浏览器失败,请手动访问 http://127.0.0.1:$Port/portal/)" -ForegroundColor DarkGray
|
||
}
|
||
}
|
||
|
||
Write-Host "`n=== 启动完成。服务在独立窗口里运行,关掉本窗口不影响它们。===" -ForegroundColor Cyan
|