feat: 完成项目助手与监控演示并修复聊天及云部署配置
This commit is contained in:
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
@@ -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;});
|
||||
Reference in New Issue
Block a user