feat: 完成项目助手与监控演示并修复聊天及云部署配置

This commit is contained in:
NathanAlex7
2026-09-11 15:56:28 +08:00
parent c57d291f65
commit af1a1027d7
75 changed files with 5215 additions and 101 deletions
+83
View File
@@ -0,0 +1,83 @@
"""Reproducible offline acceptance and latency evidence in an isolated database."""
import argparse
import base64
import json
import os
import statistics
import sys
import tempfile
import time
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "backend"))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path, default=ROOT / "test-evidence" / ("assistant-acceptance-" + datetime.now().strftime("%Y%m%d-%H%M%S")))
args = parser.parse_args()
output = args.output.resolve()
if not output.is_relative_to(ROOT / "test-evidence"):
raise ValueError("验收输出必须位于当前仓库 test-evidence")
output.mkdir(parents=True, exist_ok=False)
os.environ.update(XINGBANG_LOCAL_MODEL_URL="", XINGBANG_AUTH_USERS="", XINGBANG_REQUIRE_AUTH="0")
from api import risk_agent_config as catalog
from services.project_assistant import ProjectAssistantService
from services.assistant_worker import DurableWorker
from services.assistant_store import listing
identity = {"id": "offline-acceptance", "role": "admin", "projects": ["*"], "preview": True}
records = []
with tempfile.TemporaryDirectory(dir=output, ignore_cleanup_errors=True) as tmp:
catalog.DB_PATH = Path(tmp) / "catalog.sqlite3"
service = ProjectAssistantService(Path(tmp) / "assistant.sqlite3")
worker = DurableWorker(service)
def run(project_id):
start = time.perf_counter()
queued = service.enqueue(project_id, identity, "核对现金流与来源")
queued_ms = (time.perf_counter() - start) * 1000
worker.process_next()
result = service.resource("run", queued["id"])
records.append({"project": project_id, "run_id": result["id"], "enqueue_ms": round(queued_ms, 2), "total_ms": round((time.perf_counter() - start) * 1000, 2), "status": result["status"], "summary": result["summary"]})
return result
first = run("DEMO-B")
assert first["summary"]["coverage"] == "1.0800" and first["summary"]["open"] == 4
project = service.resource("project", "DEMO-B")
service.supplement("DEMO-B", identity["id"], {"due_diligence_liabilities": "13200", "duplicate_asset_count": 0}, [], project["revision"], "离线验收补件")
second = run("DEMO-B")
assert len(second["change_summary"]["resolved"]) == 2
scenario = service.compare_scenario("DEMO-B", "4000", identity["id"])
assert scenario["scenario"]["coverage"] == "1.3500" and len(scenario["unresolved"]) == 1
feedback = service.feedback("DEMO-B", identity["id"], {"category": "rule_omission", "target_kind": "run", "target_id": second["id"], "note": "追加回款凭证检查"})
experience = service.save_experience("DEMO-B", identity["id"], {"title": "回款凭证检查", "trigger": "存在回款依赖", "action": "补充到账依据", "exceptions": "已有到账凭证", "scope": "team", "team": "demo", "feedback_id": feedback["id"], "conditions": {"collection.dependency": True}, "required_field": "collection.evidence", "exception_field": "collection.evidence", "expires_at": "2099-01-01T00:00:00+00:00"})
experience = service.experience_action("DEMO-B", experience["id"], identity, "evaluate", experience["revision"], "隔离正反例评测")
service.experience_action("DEMO-B", experience["id"], identity, "publish", experience["revision"], "本机模拟独立审核")
c = run("DEMO-C")
a = run("DEMO-A")
assert any(f["code"].startswith("experience.") for f in c["findings"])
assert not any(f["code"].startswith("experience.") for f in a["findings"])
reports = []
for template in ("template.due-diligence", "template.review", "template.committee"):
report = service.create_report("DEMO-B", second["id"], identity["id"], template)
target = output / (template + ".docx")
target.write_bytes(service.export_report("DEMO-B", report["id"]))
reports.append({"path": target.name, "template": template, "snapshot_id": report["snapshot_id"], "visual_qa": "pending-renderer"})
times = []
for _ in range(30):
start = time.perf_counter()
service.resource("run", second["id"])
times.append((time.perf_counter() - start) * 1000)
with service.connect() as db:
for document in listing(db, "document"):
(output / document["name"]).write_bytes(base64.b64decode(document["raw_base64"]))
summary = {"mode": "offline-deterministic-no-live-model", "records": records, "scenario": scenario,
"poll_ms_p95": round(sorted(times)[28], 2), "poll_ms_median": round(statistics.median(times), 2),
"reports": reports, "experience_positive_and_negative": "passed", "preserved_live_database": True}
(output / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"output": str(output), "mode": summary["mode"], "poll_ms_p95": summary["poll_ms_p95"], "records": records}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+86
View File
@@ -0,0 +1,86 @@
"""Read-only migration audit by default; apply creates a consistent backup first.
Use backend/venv/Scripts/python.exe scripts/assistant_migrate.py --dry-run.
Restore produces a separate recovered database, never overwrites post-release writes.
"""
import argparse
import json
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "backend"))
def audit(path):
if not path.is_file():
return {"database": str(path), "exists": False, "action": "initialize-demo-instance", "dangling_references": []}
with sqlite3.connect(path.as_uri() + "?mode=ro", uri=True) as db:
tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
counts = {name: db.execute('SELECT count(*) FROM "' + name + '"').fetchone()[0] for name in tables if name in ("projects", "runs", "snapshots", "reports", "experiences", "assistant_objects", "assistant_jobs")}
dangling = []
if "assistant_objects" in tables:
objects = {(r[0], r[1]): json.loads(r[2]) for r in db.execute("SELECT kind,id,body FROM assistant_objects")}
for (kind, key), body in objects.items():
for field, target in (("snapshot_id", "snapshot"), ("input_snapshot_id", "input_snapshot"), ("run_id", "run"), ("document_ids", "document")):
if kind not in ("run", "report", "report_final", "project", "experience_application"):
continue
values = body.get(field)
for value in (values if isinstance(values, list) else [values]):
if value and (target, value) not in objects:
dangling.append({"kind": kind, "id": key, "field": field, "missing": value})
return {"database": str(path), "exists": True, "counts": counts, "integrity": db.execute("PRAGMA integrity_check").fetchone()[0], "dangling_references": dangling}
def backup(source, target):
if target.exists() or source == target:
raise ValueError("备份目标已存在或与源相同,拒绝覆盖")
target.parent.mkdir(parents=True, exist_ok=True)
original = sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)
output = sqlite3.connect(target)
try:
original.backup(output)
finally:
output.close()
original.close()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--database", type=Path, default=ROOT / "backend/xingbang_data/project_assistant.sqlite3")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--apply", action="store_true")
parser.add_argument("--restore-copy", type=Path, help="Output recovered DB alongside current DB; never replace live data")
args = parser.parse_args()
path = args.database.resolve()
if not path.is_relative_to(ROOT):
raise ValueError("迁移数据库须位于当前工作区")
before = audit(path)
if before.get("integrity", "ok") != "ok" or before["dangling_references"]:
print(json.dumps(before, ensure_ascii=False, indent=2))
return 1
if args.restore_copy:
target = args.restore_copy.resolve()
if not target.is_relative_to(ROOT) or target == path:
raise ValueError("恢复副本须为工作区内的新路径")
backup(path, target)
print(json.dumps({"restored_copy": str(target), "live_data_untouched": True, "audit": audit(target)}, ensure_ascii=False, indent=2))
return 0
if args.apply and not args.dry_run:
if path.exists():
target = ROOT / ("_backup_pre_assistant_migration_" + datetime.now().strftime("%Y%m%d_%H%M%S")) / path.name
backup(path, target)
before["backup"] = str(target)
from services.project_assistant import ProjectAssistantService
ProjectAssistantService(path)
result = {"before": before, "after": audit(path), "legacy_tables": "preserved-read-only"}
else:
result = {"mode": "dry-run", **before, "writes": 0}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+53
View File
@@ -0,0 +1,53 @@
"""Opt-in tiny model probe: never sends project files/history or prints secrets."""
import json
import os
from pathlib import Path
import sys
import time
import httpx
from dotenv import load_dotenv
load_dotenv(Path(__file__).resolve().parents[1] / "backend" / ".env")
def probe(thinking_disabled=False):
endpoint = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com").rstrip("/")
if not endpoint.endswith("/v1") and endpoint.endswith("api.deepseek.com"):
endpoint += "/v1"
payload = {"model": os.getenv("DEEPSEEK_MODEL", "deepseek-chat"), "messages": [{"role":"user", "content":"请只回复:连接正常。"}], "stream": True, "max_tokens": 1024}
if thinking_disabled:
payload["thinking"] = {"type":"disabled"}
started = time.monotonic()
result = {"mode":"thinking_disabled" if thinking_disabled else "default", "content_chars":0, "reasoning_chars":0, "keys":[], "finished":False}
try:
with httpx.Client(trust_env=False, timeout=30) as client:
with client.stream("POST", endpoint + "/chat/completions", headers={"Authorization":"Bearer " + os.getenv("DEEPSEEK_API_KEY", "")}, json=payload) as response:
result["http_status"] = response.status_code
if response.status_code != 200:
return result
for line in response.iter_lines():
if time.monotonic() - started > 40:
result["timeout"] = True
break
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
result["finished"] = True
break
item = json.loads(data)
for choice in item.get("choices", []):
delta = choice.get("delta", {})
result["keys"] = sorted(set(result["keys"]) | set(delta))
result["content_chars"] += len(delta.get("content") or "")
result["reasoning_chars"] += len(delta.get("reasoning_content") or "")
if choice.get("finish_reason"):
result["finish_reason"] = choice["finish_reason"]
except Exception as exc:
result["error_type"] = type(exc).__name__
result["duration_seconds"] = round(time.monotonic() - started, 2)
return result
if __name__ == "__main__":
print(json.dumps(probe("--thinking-disabled" in sys.argv), ensure_ascii=False))
+46
View File
@@ -0,0 +1,46 @@
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$backendRoot = Join-Path $projectRoot 'backend'
$runLogDir = Join-Path $projectRoot ('test-evidence/local-operations-' + (Get-Date -Format 'yyyyMMdd-HHmmss'))
New-Item -ItemType Directory -Path $runLogDir -Force | Out-Null
function Test-Listener([int]$Port) {
return [bool](Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue)
}
# These are child-process settings only. Existing Docker services are untouched.
$env:HF_HUB_OFFLINE = '1'
$env:TRANSFORMERS_OFFLINE = '1'
$env:NO_PROXY = '*'
$env:HTTP_PROXY = ''
$env:HTTPS_PROXY = ''
$env:ALL_PROXY = ''
$env:PYTHONIOENCODING = 'utf-8'
$env:RISK_AGENT_BACKEND_URL = 'http://127.0.0.1:8003'
if (-not (Test-Listener 8003)) {
$pythonPath = Join-Path $backendRoot 'venv/Scripts/python.exe'
$backendProcess = Start-Process -FilePath $pythonPath -ArgumentList '-u','-m','uvicorn','app:app','--host','127.0.0.1','--port','8003' -WorkingDirectory $backendRoot -WindowStyle Hidden -PassThru -RedirectStandardOutput (Join-Path $runLogDir 'backend.log') -RedirectStandardError (Join-Path $runLogDir 'backend-error.log')
Write-Output "Backend starting: PID $($backendProcess.Id), port 8003"
} else {
Write-Output 'Port 8003 is occupied; verifying existing service without stopping it.'
}
if (-not (Test-Listener 3000)) {
$nodePath = (Get-Command node.exe).Source
$nextPath = Join-Path $projectRoot 'node_modules/next/dist/bin/next'
$frontProcess = Start-Process -FilePath $nodePath -ArgumentList ('"' + $nextPath + '"'),'dev','--hostname','127.0.0.1','--port','3000' -WorkingDirectory $projectRoot -WindowStyle Hidden -PassThru -RedirectStandardOutput (Join-Path $runLogDir 'frontend.log') -RedirectStandardError (Join-Path $runLogDir 'frontend-error.log')
Write-Output "Frontend starting: PID $($frontProcess.Id), port 3000"
} else {
Write-Output 'Port 3000 is occupied; verifying existing service without stopping it.'
}
$startupDeadline = (Get-Date).AddMinutes(4)
$healthy = $false
while ((Get-Date) -lt $startupDeadline) {
try {
$probe = Invoke-RestMethod 'http://127.0.0.1:3000/api/risk-agent/project-assistant/operations/overview?domain=lab' -TimeoutSec 5
if ($probe.domain -eq 'lab') { $healthy = $true; break }
} catch { }
Start-Sleep -Seconds 2
}
if (-not $healthy) { throw "Startup check failed. Check logs or authentication: $runLogDir. No occupied process was stopped." }
Write-Output 'Ready: http://127.0.0.1:3000/operations-center?domain=lab&view=lab'
Write-Output "Logs: $runLogDir"
+62
View File
@@ -0,0 +1,62 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require('typescript');
const root = path.resolve(__dirname, '..');
function load(relative, overrides = {}) {
const compiled = ts.transpileModule(fs.readFileSync(path.join(root,relative),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText;
const exports = {};
vm.runInNewContext(compiled,{exports,require:id=>overrides[id]||require(id),process,fetch:global.fetch,Headers,Response,AbortController,TextDecoder,TextEncoder,setTimeout,clearTimeout,URL});
return exports;
}
const {ChatEventDecoder,readChatStream} = load('src/lib/chat-stream.ts');
const encode = x => new TextEncoder().encode(x);
const event = (kind,data) => `event: ${kind}\r\ndata: ${JSON.stringify(data)}\r\n\r\n`;
async function collect(iterator) {const result=[];for await(const item of iterator) result.push(item);return result;}
function responder(chunks) {return async()=>new Response(new ReadableStream({start(controller){for(const chunk of chunks)controller.enqueue(encode(chunk));controller.close();}}),{headers:{'Content-Type':'text/event-stream'}});}
(async()=>{
const original=event('token',{content:'你好'})+event('done',{content:'你好'});
for(let split=0;split<=original.length;split++) {
const parser=new ChatEventDecoder();
const items=[...parser.push(original.slice(0,split)),...parser.push(original.slice(split))];
assert.equal(items.length,2);assert.equal(items[0].event,'token');assert.equal(items[0].data.content,'你好');
}
const parser=new ChatEventDecoder();
const items=[];for(const char of original)items.push(...parser.push(char));assert.equal(items.length,2);
assert.equal(new ChatEventDecoder().push('event: token\ndata: {"content":\ndata: "multiline"}\n\n')[0].data.content,'multiline');
assert.equal(new ChatEventDecoder().push(': heartbeat\r\n\r\n').length,0);
assert.throws(()=>new ChatEventDecoder().push('event: token\ndata: invalid\n\n'),/格式/);
const normal=await collect(readChatStream('/test',{}, {fetcher:responder([original.slice(0,8),original.slice(8)])}));
assert.equal(normal[0].event,'token');assert.equal(normal[1].event,'done');
const finalOnly=await collect(readChatStream('/test',{}, {fetcher:responder([event('done',{content:'final'})])}));
assert.equal(finalOnly[0].event,'token');assert.equal(finalOnly[0].data.content,'final');
await assert.rejects(()=>collect(readChatStream('/test',{}, {fetcher:responder([event('done',{content:''})])})),/正文/);
await assert.rejects(()=>collect(readChatStream('/test',{}, {fetcher:responder([event('token',{content:'partial'})])})),/中断/);
const error=await collect(readChatStream('/test',{}, {fetcher:responder([event('error',{error:'model failed'})])}));assert.equal(error[0].event,'error');
let aborted=false;
await assert.rejects(()=>collect(readChatStream('/test',{}, {idleMs:10,totalMs:100,fetcher:async(_url,{signal})=>new Promise((_resolve,reject)=>signal.addEventListener('abort',()=>{aborted=true;reject(new Error('aborted'));}))})),/超时/);
assert.equal(aborted,true);
await assert.rejects(()=>collect(readChatStream('/test',{}, {fetcher:async()=>new Response('bad',{status:502})})),/502/);
await assert.rejects(()=>collect(readChatStream('/test',{}, {fetcher:async()=>new Response('{}',{headers:{'Content-Type':'application/json'}})})),/事件流/);
const source=fs.readFileSync(path.join(root,'src/lib/api.ts'),'utf8');assert.ok(source.includes('const API_BASE = "/api/core"'));assert.ok(!source.includes(':8002'));
const NextResponse = class extends Response {static json(data,init){return new Response(JSON.stringify(data),{...init,headers:{'Content-Type':'application/json'}});}};
const oldFetch=global.fetch, oldBase=process.env.RISK_AGENT_BACKEND_URL, oldCore=process.env.CORE_BACKEND_URL;
try {
delete process.env.CORE_BACKEND_URL;process.env.RISK_AGENT_BACKEND_URL='http://127.0.0.1:8003';
let target, options;
const upstream=new Response(new ReadableStream({start(c){c.enqueue(encode(event('token',{content:'stream'})));}}),{headers:{'Content-Type':'text/event-stream'}});
global.fetch=async(url,opts)=>{target=url;options=opts;return upstream;};
const route=load('src/app/api/core/[...path]/route.ts',{'next/server':{NextResponse}});
const request={nextUrl:{hostname:'127.0.0.1',search:'?id=abc'},headers:new Headers(),method:'POST',arrayBuffer:async()=>encode('{}'),signal:new AbortController().signal};
const response=await route.POST(request,{params:{path:['chat']}});
assert.equal(target,'http://127.0.0.1:8003/api/chat?id=abc');assert.equal(response.body,upstream.body);assert.equal(options.signal,request.signal);
const reader=response.body.getReader();assert.ok((await reader.read()).value.length);await reader.cancel();
assert.equal((await route.GET({...request,nextUrl:{hostname:'example.org'}},{params:{path:['chat']}})).status,403);
assert.equal((await route.GET(request,{params:{path:['chat','..']}})).status,404);
assert.equal((await route.GET(request,{params:{path:['unknown']}})).status,404);
} finally {global.fetch=oldFetch;if(oldBase===undefined)delete process.env.RISK_AGENT_BACKEND_URL;else process.env.RISK_AGENT_BACKEND_URL=oldBase;if(oldCore===undefined)delete process.env.CORE_BACKEND_URL;else process.env.CORE_BACKEND_URL=oldCore;}
console.log('PASS: all SSE split boundaries, CRLF, multiline, final-only, empty, timeout, premature EOF, errors, streaming proxy and local-only guard');
})().catch(error=>{console.error(error);process.exitCode=1;});