Files
group_fqcd_jr/start.ps1
张胜宇 2a55269e20 feat(W34-W36): 客服双通道口径分离收口 + 会签 20/21/22 落地 + 演示启动器修复
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 仍默认关闭。
2026-09-22 18:05:59 +08:00

331 lines
16 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.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