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
+60 -4
View File
@@ -1,6 +1,7 @@
"""POST /api/chat — SSE streaming chat with Agent."""
import json
import asyncio
import os
import traceback
from pathlib import Path
@@ -12,6 +13,7 @@ from sse_starlette.sse import EventSourceResponse
from graph.agent import agent_manager
from graph.session_manager import session_manager
from graph.chat_model_options import chat_model_options
router = APIRouter()
@@ -47,6 +49,7 @@ async def _generate_title(session_id: str) -> str | None:
api_key=os.getenv("DEEPSEEK_API_KEY"),
api_base=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
temperature=0.3,
**chat_model_options(),
)
prompt = (
@@ -64,6 +67,27 @@ async def _generate_title(session_id: str) -> str | None:
return None
class EmptyChatResponse(RuntimeError):
pass
async def bounded_events(iterator, idle_seconds=75, total_seconds=180):
"""Bound stalls and total execution; never replay possible tool side effects."""
deadline = asyncio.get_running_loop().time() + total_seconds
try:
while True:
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
raise TimeoutError()
try:
item = await asyncio.wait_for(anext(iterator), timeout=min(idle_seconds, remaining))
except StopAsyncIteration:
break
yield item
finally:
await iterator.aclose()
async def event_generator(message: str, session_id: str) -> AsyncGenerator[dict, None]:
"""Generate SSE events from agent stream.
@@ -72,6 +96,10 @@ async def event_generator(message: str, session_id: str) -> AsyncGenerator[dict,
emitted and a new segment begins. Each segment is saved as a
separate assistant message in the session history.
"""
segments: list[dict] = []
current_segment: dict = {"content": "", "tool_calls": []}
saved = False
completed = False
try:
# Use merged history for agent context (combines consecutive assistant msgs)
history = session_manager.load_session_for_agent(session_id)
@@ -81,7 +109,7 @@ async def event_generator(message: str, session_id: str) -> AsyncGenerator[dict,
segments: list[dict] = [] # finalized segments
current_segment: dict = {"content": "", "tool_calls": []}
async for event in agent_manager.astream(message, history, session_id):
async for event in bounded_events(agent_manager.astream(message, history, session_id)):
event_type = event.get("type", "unknown")
if event_type == "retrieval":
@@ -137,6 +165,12 @@ async def event_generator(message: str, session_id: str) -> AsyncGenerator[dict,
}
elif event_type == "done":
if not str(event.get("content") or "").strip():
raise EmptyChatResponse()
# Some providers deliver final content without token events.
if not current_segment["content"] and not any(s["content"] for s in segments):
current_segment["content"] = event["content"]
yield {"event":"token", "data":json.dumps({"content":event["content"]}, ensure_ascii=False)}
# Finalize the last segment
segments.append(current_segment)
@@ -145,10 +179,14 @@ async def event_generator(message: str, session_id: str) -> AsyncGenerator[dict,
# Save each segment as a separate assistant message
for seg in segments:
if not seg["content"].strip() and not seg["tool_calls"]:
continue
tc = seg["tool_calls"] if seg["tool_calls"] else None
session_manager.save_message(
session_id, "assistant", seg["content"], tool_calls=tc
)
saved = True
completed = True
yield {
"event": "done",
@@ -160,7 +198,10 @@ async def event_generator(message: str, session_id: str) -> AsyncGenerator[dict,
# Auto-generate title on first conversation
if is_first_message:
title = await _generate_title(session_id)
try:
title = await asyncio.wait_for(_generate_title(session_id), timeout=8)
except TimeoutError:
title = None
if title:
yield {
"event": "title",
@@ -170,11 +211,26 @@ async def event_generator(message: str, session_id: str) -> AsyncGenerator[dict,
),
}
if not completed:
raise ConnectionError("stream ended before done")
except Exception as e:
traceback.print_exc()
is_timeout = isinstance(e, TimeoutError) or type(e).__name__ in ("APITimeoutError", "ReadTimeout", "ConnectTimeout")
code = "empty_response" if isinstance(e, EmptyChatResponse) else "timeout" if is_timeout else "chat_failed"
message_text = {
"empty_response":"模型未返回回答正文,可能只返回了推理字段。请检查聊天模型兼容配置后重试。",
"timeout":"模型响应超时,已停止本次等待。请检查服务后重试;不会自动重复执行工具。",
"chat_failed":"聊天未完成,请检查当前后端、模型连接或访问凭据后重试。已接收的内容会保留。",
}[code]
print(f"Chat failed: {type(e).__name__}")
if not saved:
session_manager.save_message(session_id, "user", message)
for seg in segments:
if seg["content"] or seg["tool_calls"]:
session_manager.save_message(session_id, "assistant", seg["content"], tool_calls=seg["tool_calls"] or None)
session_manager.save_message(session_id, "assistant", current_segment["content"] + "\n\n【未完成】" + message_text, tool_calls=current_segment["tool_calls"] or None)
yield {
"event": "error",
"data": json.dumps({"error": str(e)}, ensure_ascii=False),
"data": json.dumps({"error": message_text, "code": code}, ensure_ascii=False),
}
+1 -2
View File
@@ -101,8 +101,7 @@ def workspace(request: Request):
storage(db)
cases = rows(db, "collateral_cases")
user = request.state.identity
if user["role"] == "manager":
cases = [c for c in cases if "*" in user.get("projects", []) or c["project_id"] in user.get("projects", [])]
cases = [c for c in cases if "*" in user.get("projects", []) or c["project_id"] in user.get("projects", [])]
return {"cases": [decorated(db, c) for c in cases], "rules": rows(db, "collateral_rules"), "identity": user, "kinds": KINDS}
+1
View File
@@ -69,6 +69,7 @@ def workspace(request: Request):
projects = all_rows(db, "coordination_projects")
people = all_rows(db, "coordination_people")
user = request.state.identity
projects = [p for p in projects if "*" in user.get("projects", []) or p["project_id"] in user.get("projects", [])]
if user["role"] == "manager":
projects = [p for p in projects if "*" in user.get("projects", []) or p["project_id"] in user.get("projects", [])]
for project in projects:
+210
View File
@@ -0,0 +1,210 @@
"""Read-only scoped monitoring and admin-only isolated demonstration actions."""
import json
import threading
import time
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import Field
from api.risk_agent_access import authorize, check_project
from api.project_assistant import Input, enabled, role, translate
from services.project_assistant import ProjectAssistantService
from services.operations_demo import DemoService, PROJECT, regression
from services.assistant_store import get, put, listing, event, now
from services.assistant_model import configuration
from services.assistant_settings import flags
from services.assistant_worker import LEASE_SECONDS
router = APIRouter(prefix="/api/xingbang/risk-agent/project-assistant/operations", dependencies=[Depends(authorize), Depends(enabled)], tags=["运行中心"])
EVALUATION_LOCK = threading.Lock()
SUBMIT_LOCK = threading.Lock()
def current_service():
return ProjectAssistantService()
def demo_service():
return DemoService()
def lab_guard(request, write=False):
if not flags()["demo"]:
raise HTTPException(403, "演示数据域已禁用")
check_project(request, PROJECT)
if write:
role(request, ("admin",))
def selected_service(request, domain):
if domain == "lab":
lab_guard(request)
return demo_service()
return current_service()
def visible(project, identity):
return ("*" in identity.get("projects", []) or project["id"] in identity.get("projects", [])) and (flags()["demo"] or project.get("data_domain") != "demo")
def seconds(start, end=None):
if not start:
return None
return round(max(0, (datetime.fromisoformat(end or now()) - datetime.fromisoformat(start)).total_seconds()), 3)
def summarize(run, job=None):
result = {k: run.get(k) for k in ("id", "project_id", "status", "created_at", "started_at", "ended_at", "attempt", "summary", "error", "superseded", "input_snapshot_id")}
result["elapsed_seconds"] = seconds(run.get("started_at"), run.get("ended_at"))
result["queue_seconds"] = seconds(run.get("created_at"), run.get("started_at") or run.get("ended_at"))
active = next((s for s in run["steps"] if s["status"] == "running"), None)
result["step"] = active["name"] if active else (run["steps"][-1]["name"] if run["steps"] else "等待工作进程领取")
result["lease_expired"] = bool(job and job["state"] == "running" and job["lease_until"] < time.time())
result["heartbeat_at"] = datetime.fromtimestamp(job["lease_until"] - LEASE_SECONDS).astimezone().isoformat() if job and job["state"] == "running" else None
return result
@router.get("/overview")
def overview(request: Request, domain: Literal["current", "lab"] = "current"):
service = selected_service(request, domain)
with service.connect() as db:
projects = [p for p in listing(db, "project") if visible(p, request.state.identity)]
ids = {p["id"] for p in projects}
runs = [r for r in listing(db, "run") if r["project_id"] in ids]
jobs = {r["id"]: dict(r) for r in db.execute("SELECT id,state,lease_until FROM assistant_jobs") if r["id"] in {x["id"] for x in runs}}
findings = [f for f in listing(db, "finding") if f["project_id"] in ids and f["status"] not in ("已解决", "不适用")]
stale = [r for r in listing(db, "report") if r["project_id"] in ids and r.get("status") == "stale"]
counts = {key: sum(r["status"] == key for r in runs) for key in ("queued", "running", "completed", "partial", "failed", "cancelled")}
data = {"domain": domain, "time": now(), "identity": request.state.identity, "counts": counts,
"projects": [{"id": p["id"], "name": p["name"], "data_revision": p["data_revision"]} for p in projects],
"runs": [summarize(r, jobs.get(r["id"])) for r in runs[:100]], "total_runs": len(runs),
"todo_count": len(findings), "stale_report_count": len(stale),
"todos": [{k: f.get(k) for k in ("id", "project_id", "title", "status", "stale", "suggested_action", "last_seen_run_id")} for f in findings[:50]],
"stale_reports": [{k: r.get(k) for k in ("id", "project_id", "status")} for r in stale[:30]],
"evaluations": [e for e in listing(db, "operations_evaluation") if e["project_id"] in ids][:10]}
return data
@router.get("/runs/{run_id}")
def run_detail(run_id: str, request: Request, domain: Literal["current", "lab"] = "current"):
service = selected_service(request, domain)
run = translate(lambda: service.resource("run", run_id))
check_project(request, run["project_id"])
project = service.resource("project", run["project_id"])
if not visible(project, request.state.identity):
raise HTTPException(403, "无权读取该数据域")
with service.connect() as db:
release = {r["id"]: r["version_id"] for r in listing(db, "release")}
components = run["components"]
applied = []
for component in [components["plan"]] + components["skills"] + components["rules"] + [components["template"]]:
applied.append({k: component.get(k) for k in ("id", "name", "kind", "version", "content_hash", "engine", "inputs", "conditions")})
applied[-1]["current_version"] = release.get(component["id"], "旧配置目录")
applied[-1]["changed"] = component["id"] in release and release[component["id"]] != component["id"] + "@" + component["version"]
snapshot = get(db, "input_snapshot", run["input_snapshot_id"])
events = [dict(seq=r[0], **json.loads(r[1])) for r in db.execute("SELECT seq,body FROM assistant_events WHERE project_id=? AND run_id=? ORDER BY seq", (run["project_id"], run_id))]
result = service.public_run(run)
result["steps"] = [{**{k: v for k, v in s.items() if k != "trace"}, "elapsed_seconds": seconds(s.get("started_at"), s.get("ended_at"))} for s in run["steps"]]
return {"run": result, "components": applied, "events": events, "source_revision": snapshot.get("lab_source_revision"),
"source_hash": snapshot.get("lab_source_hash"), "data_revision": snapshot["data_revision"], "values": snapshot["values"]}
@router.get("/lab/source")
def source(request: Request):
lab_guard(request)
lab = demo_service()
with lab.connect() as db:
release = get(db, "release", "lab.coverage")
rule = get(db, "component_version", release["version_id"])
project = get(db, "project", PROJECT)
return {**lab.source(), "release": release, "threshold": rule["conditions"][0]["threshold"],
"synced_revision": project.get("lab_source_revision"), "database": "operations_demo.sqlite3", "isolated": True}
class SourceInput(Input):
revision: int = Field(ge=1)
amount: str = Field(max_length=40)
liabilities: str = Field(max_length=40)
dd_liabilities: str = Field(max_length=40)
cashflow: str | None = Field(default=None, max_length=40)
duplicate: bool = True
enabled: bool = True
@router.post("/lab/source")
def edit_source(payload: SourceInput, request: Request):
lab_guard(request, True)
values = payload.model_dump()
revision = values.pop("revision")
return translate(lambda: demo_service().edit_source(request.state.identity["id"], revision, **values))
class SubmitInput(Input):
source_revision: int = Field(ge=1)
model: bool = False
requirement: str = Field(default="", max_length=2000)
@router.post("/lab/runs", status_code=202)
def submit(payload: SubmitInput, request: Request):
lab_guard(request, True)
key = request.headers.get("idempotency-key", "")
if not key or len(key) > 160:
raise HTTPException(422, "请提供不超过160字符的幂等键")
if payload.requirement.strip() and not payload.model:
raise HTTPException(422, "自然语言要求需要启用真实模型步骤,确定性模式不会解释提示词")
def perform():
with SUBMIT_LOCK:
lab = demo_service()
project = lab.ingest(request.state.identity["id"], payload.source_revision)
return lab.enqueue(PROJECT, request.state.identity, payload.requirement, "lab.model" if payload.model else "lab.deterministic", key, project["revision"])
return translate(perform)
class RuleInput(Input):
version: Literal["1.0.0", "2.0.0"]
revision: int = Field(ge=1)
@router.post("/lab/rule")
def switch_rule(payload: RuleInput, request: Request):
lab_guard(request, True)
return translate(lambda: demo_service().switch_rule(request.state.identity["id"], payload.version, payload.revision))
@router.post("/lab/runs/{run_id}/cancel")
def cancel(run_id: str, request: Request):
lab_guard(request, True)
return translate(lambda: demo_service().cancel(PROJECT, run_id, request.state.identity["id"]))
@router.get("/health")
def health(request: Request):
role(request, ("admin",))
service = current_service()
with service.connect() as db:
integrity = db.execute("PRAGMA quick_check").fetchone()[0]
try:
model = configuration()
except ValueError:
model = {"available": False}
return {"time": now(), "checks": [
{"name": "FastAPI / Next.js 代理", "status": "ok", "detail": "本请求已成功到达后端;不代表所有业务链路均可用"},
{"name": "项目 SQLite 完整性", "status": "ok" if integrity == "ok" else "error", "detail": "PRAGMA quick_check: " + integrity},
{"name": "业务模型", "status": "unverified" if model.get("available") else "unavailable", "detail": "已配置,尚未执行连通性及质量测试" if model.get("available") else "未配置可用内网模型;确定性演示可独立运行"},
{"name": "验证边界", "status": "unverified", "detail": "本页不验证 OCR、Word 版式或 ERP;不展示虚构 Token、成本或通过率"}]}
@router.post("/lab/evaluate")
def evaluate(request: Request):
lab_guard(request, True)
if not EVALUATION_LOCK.acquire(blocking=False):
raise HTTPException(409, "已有隔离验收在执行,请稍后查看")
try:
result = translate(lambda: regression(request.state.identity))
with demo_service().connect() as db:
put(db, "operations_evaluation", result, immutable=True)
event(db, PROJECT, "lab.evaluated", request.state.identity["id"], {"evaluation_id": result["id"], "passed": result["passed"]})
return result
finally:
EVALUATION_LOCK.release()
+299
View File
@@ -0,0 +1,299 @@
"""Unified project API. Every resource read/write derives scope from its project."""
import base64
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from fastapi.responses import Response
from pydantic import BaseModel, Field, ConfigDict
from api.risk_agent_access import authorize, check_project
from services.project_assistant import ProjectAssistantService
from services.assistant_store import Conflict, listing, get, put, event, digest, uid, now
from services import component_registry as registry
from services.assistant_settings import flags
def enabled():
if not flags()["assistant"]:
raise HTTPException(503, "新项目助手已暂停;旧工作台和历史入口保持可用")
router = APIRouter(prefix="/api/xingbang/risk-agent/project-assistant", dependencies=[Depends(authorize), Depends(enabled)], tags=["兴邦项目助手"])
def service():
return ProjectAssistantService()
def guard(request, project_id):
check_project(request, project_id)
if project_id in ("DEMO-A", "DEMO-B", "DEMO-C") and not flags()["demo"]:
raise HTTPException(403, "演示数据域已禁用")
def role(request, allowed):
if not request.state.identity.get("preview") and request.state.identity["role"] not in allowed:
raise HTTPException(403, "当前角色无权执行此操作")
def translate(call):
try:
return call()
except KeyError as exc:
raise HTTPException(404, str(exc.args[0])) from exc
except Conflict as exc:
raise HTTPException(409, str(exc)) from exc
except PermissionError as exc:
raise HTTPException(403, str(exc)) from exc
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
class Input(BaseModel):
model_config = ConfigDict(extra="forbid")
class RunInput(Input):
requirement: str = Field(default="", max_length=2000)
plan_id: str = "corporate.default"
revision: int | None = None
feedback_id: str | None = None
class SupplementInput(Input):
changes: dict = Field(default_factory=dict)
finding_ids: list[str] = Field(default_factory=list, max_length=100)
candidate_ids: list[str] = Field(default_factory=list, max_length=100)
revision: int
reason: str = Field(min_length=1, max_length=2000)
class ScenarioInput(Input):
facility_amount: str = Field(max_length=50)
class ReportInput(Input):
run_id: str
template_id: str = "template.due-diligence"
class ActionInput(Input):
action: str = Field(max_length=50)
revision: int
note: str = Field(default="", max_length=2000)
payload: dict = Field(default_factory=dict)
class FeedbackInput(Input):
category: str
note: str = Field(min_length=1, max_length=3000)
target_kind: str
target_id: str
class ExperienceInput(Input):
title: str = Field(min_length=1, max_length=200)
trigger: str = Field(min_length=1, max_length=2000)
action: str = Field(min_length=1, max_length=2000)
exceptions: str = Field(min_length=1, max_length=2000)
feedback_id: str
conditions: dict
required_field: str
exception_field: str
scope: str = "project"
team: str = ""
industry: str = ""
expires_at: str
@router.get("/projects")
def projects(request: Request):
allowed = request.state.identity.get("projects", [])
items = [p for p in service().list_projects() if ("*" in allowed or p["id"] in allowed) and (flags()["demo"] or p["data_domain"] != "demo")]
return {"projects": items, "identity": request.state.identity}
@router.get("/projects/{project_id}")
def project(project_id: str, request: Request):
guard(request, project_id)
return translate(lambda: service().get_project(project_id))
@router.post("/projects/{project_id}/runs", status_code=202)
def create_run(project_id: str, payload: RunInput, request: Request):
guard(request, project_id)
key = request.headers.get("idempotency-key", "")
if len(key) > 160:
raise HTTPException(422, "幂等键过长")
return translate(lambda: service().enqueue(project_id, request.state.identity, payload.requirement, payload.plan_id, key, payload.revision, payload.feedback_id))
@router.get("/projects/{project_id}/runs/{run_id}")
def run(project_id: str, run_id: str, request: Request):
guard(request, project_id)
return translate(lambda: service().public_run(service().resource("run", run_id, project_id)))
@router.post("/projects/{project_id}/runs/{run_id}/cancel")
def cancel(project_id: str, run_id: str, request: Request):
guard(request, project_id)
return translate(lambda: service().cancel(project_id, run_id, request.state.identity["id"]))
@router.get("/projects/{project_id}/snapshots/{snapshot_id}")
def snapshot(project_id: str, snapshot_id: str, request: Request):
guard(request, project_id)
return translate(lambda: service().resource("snapshot", snapshot_id, project_id))
@router.get("/projects/{project_id}/legacy/{kind}/{legacy_id}")
def legacy(project_id: str, kind: str, legacy_id: str, request: Request):
guard(request, project_id)
if kind not in ("runs", "snapshots", "findings", "scenarios", "reports", "experiences"):
raise HTTPException(404, "历史类型不存在")
return translate(lambda: service().resource("legacy_archive", kind + ":" + legacy_id, project_id))
@router.post("/projects/{project_id}/documents", status_code=201)
async def upload(project_id: str, request: Request, file: UploadFile = File(...)):
guard(request, project_id)
from services.assistant_materials import LIMIT
try:
content = await file.read(LIMIT + 1)
finally:
await file.close()
# Parsing is synchronous CPU work; do not block the ASGI event loop.
from starlette.concurrency import run_in_threadpool
return await run_in_threadpool(lambda: translate(lambda: service().upload(project_id, file.filename or "material", content, request.state.identity["id"])))
@router.get("/projects/{project_id}/documents/{document_id}")
def document(project_id: str, document_id: str, request: Request):
guard(request, project_id)
return translate(lambda: {k: v for k, v in service().resource("document", document_id, project_id).items() if k != "raw_base64"})
@router.get("/projects/{project_id}/documents/{document_id}/download")
def download(project_id: str, document_id: str, request: Request):
guard(request, project_id)
body = translate(lambda: service().resource("document", document_id, project_id))
return Response(base64.b64decode(body["raw_base64"]), media_type="application/octet-stream", headers={"Content-Disposition": "attachment; filename*=UTF-8''" + quote(body["name"]), "X-Content-Type-Options": "nosniff"})
@router.post("/projects/{project_id}/supplements", status_code=201)
def supplement(project_id: str, payload: SupplementInput, request: Request):
guard(request, project_id)
return translate(lambda: service().supplement(project_id, request.state.identity["id"], payload.changes, payload.finding_ids, payload.revision, payload.reason, payload.candidate_ids))
@router.post("/projects/{project_id}/findings/{finding_id}/actions")
def finding_action(project_id: str, finding_id: str, payload: ActionInput, request: Request):
guard(request, project_id)
return translate(lambda: service().finding_action(project_id, finding_id, request.state.identity["id"], payload.action, payload.note, payload.revision))
@router.post("/projects/{project_id}/scenarios", status_code=201)
def scenario(project_id: str, payload: ScenarioInput, request: Request):
guard(request, project_id)
return translate(lambda: service().compare_scenario(project_id, payload.facility_amount, request.state.identity["id"]))
@router.post("/projects/{project_id}/scenarios/{scenario_id}/adopt")
def adopt(project_id: str, scenario_id: str, payload: ActionInput, request: Request):
guard(request, project_id)
return translate(lambda: service().adopt_scenario(project_id, scenario_id, request.state.identity["id"], payload.revision, payload.note))
@router.post("/projects/{project_id}/reports", status_code=201)
def report(project_id: str, payload: ReportInput, request: Request):
guard(request, project_id)
key = request.headers.get("idempotency-key", "")
if len(key) > 160:
raise HTTPException(422, "幂等键过长")
return translate(lambda: service().create_report(project_id, payload.run_id, request.state.identity["id"], payload.template_id, key))
@router.post("/projects/{project_id}/reports/{report_id}/actions")
def report_action(project_id: str, report_id: str, payload: ActionInput, request: Request):
guard(request, project_id)
if payload.action == "finalize":
role(request, ("reviewer",))
return translate(lambda: service().report_action(project_id, report_id, request.state.identity["id"], payload.action, payload.payload, payload.revision))
@router.get("/projects/{project_id}/reports/{report_id}/export")
def report_export(project_id: str, report_id: str, request: Request):
guard(request, project_id)
content = translate(lambda: service().export_report(project_id, report_id))
return Response(content, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", headers={"Content-Disposition": f'attachment; filename="{report_id}.docx"', "X-Content-Type-Options": "nosniff"})
@router.post("/projects/{project_id}/feedback", status_code=201)
def feedback(project_id: str, payload: FeedbackInput, request: Request):
guard(request, project_id)
return translate(lambda: service().feedback(project_id, request.state.identity["id"], payload.model_dump()))
@router.post("/projects/{project_id}/experiences", status_code=201)
def experience(project_id: str, payload: ExperienceInput, request: Request):
guard(request, project_id)
if payload.scope == "team" and not request.state.identity.get("preview") and payload.team not in request.state.identity.get("teams", []):
raise HTTPException(403, "无权向该团队提交经验")
return translate(lambda: service().save_experience(project_id, request.state.identity["id"], payload.model_dump()))
@router.post("/projects/{project_id}/experiences/{experience_id}/actions")
def experience_action(project_id: str, experience_id: str, payload: ActionInput, request: Request):
guard(request, project_id)
return translate(lambda: service().experience_action(project_id, experience_id, request.state.identity, payload.action, payload.revision, payload.note))
@router.get("/components")
def components(request: Request):
role(request, ("admin", "reviewer"))
with service().connect() as db:
return {"published": [registry.resolve(db, r["id"]) for r in listing(db, "release")], "drafts": listing(db, "component_draft"), "versions": listing(db, "component_version")}
@router.post("/components")
def component_draft(payload: dict, request: Request):
role(request, ("admin",))
def save():
with service().connect() as db:
db.execute("BEGIN IMMEDIATE")
return registry.draft(db, payload, request.state.identity["id"])
return translate(save)
@router.post("/components/{draft_id}/actions")
def component_action(draft_id: str, payload: ActionInput, request: Request):
role(request, ("admin", "reviewer"))
def action():
if not flags()["evaluation"]:
raise HTTPException(503, "候选评测与发布已暂停")
from services.component_evaluation import evaluate
with service().connect() as db:
db.execute("BEGIN IMMEDIATE")
if payload.action in ("evaluate", "publish") and get(db, "component_draft", draft_id)["revision"] != payload.revision:
raise Conflict("组件草稿已更新,请刷新")
if payload.action == "evaluate":
return evaluate(db, draft_id, request.state.identity["id"])
role(request, ("reviewer",))
if payload.action == "publish":
return registry.release(db, draft_id, request.state.identity["id"], request.state.identity.get("preview"))
if payload.action == "rollback":
old = get(db, "component_version", draft_id)
result = put(db, "release", {"id": old["component_id"], "version_id": old["id"], "scope": old["scope"]})
event(db, "", "component.rollback", request.state.identity["id"], {"version_id": draft_id, "note": payload.note})
return result
raise ValueError("不支持的组件动作")
return translate(action)
class ResetInput(Input):
confirmation: str
@router.post("/demo/reset")
def reset_demo(payload: ResetInput, request: Request):
role(request, ("admin",))
for project_id in ("DEMO-A", "DEMO-B", "DEMO-C"):
guard(request, project_id)
return translate(lambda: service().reset_demo(request.state.identity["id"], payload.confirmation))
+1 -1
View File
@@ -37,7 +37,7 @@ def documents(request:Request):
storage(db)
docs=[json.loads(r[0]) for r in db.execute("SELECT body FROM report_documents ORDER BY rowid DESC")]
user=request.state.identity
return [d for d in docs if user["role"]!="manager" or "*" in user.get("projects",[]) or d["project_id"] in user.get("projects",[])]
return [d for d in docs if "*" in user.get("projects",[]) or d["project_id"] in user.get("projects",[])]
class Create(BaseModel):
run_id:str
+3 -2
View File
@@ -32,16 +32,17 @@ def authorize(request: Request):
if user["role"] == "manager":
permitted = path in ("catalog", "access/context", "runs/history") or (path.startswith("plans/") and path.endswith("/run")) or (path.startswith("reports/") and request.method=="GET")
permitted = permitted or (path.startswith("coordination/") and (request.method == "GET" or path.endswith("/actions")))
permitted = permitted or path.startswith("project-assistant/")
permitted = permitted or (path.startswith(("collateral/", "workspace/")) and request.method == "GET")
if not permitted:
raise HTTPException(403, "客户经理无权管理配置")
if user["role"] == "reviewer" and request.method != "GET" and not path.endswith("/review") and not path.startswith(("reports/", "coordination/", "collateral/")):
if user["role"] == "reviewer" and request.method != "GET" and not path.endswith("/review") and not path.startswith(("reports/", "coordination/", "collateral/", "project-assistant/")):
raise HTTPException(403, "审核员仅可审核发布,不能修改配置或执行查询")
def check_project(request: Request, project_id: str):
user = request.state.identity
if user["role"] == "manager" and "*" not in user.get("projects", []) and project_id not in user.get("projects", []):
if "*" not in user.get("projects", []) and project_id not in user.get("projects", []):
raise HTTPException(403, "无权访问该项目")
+5 -4
View File
@@ -228,12 +228,12 @@ class RunInput(BaseModel):
project_id: str = Field(min_length=1,max_length=120)
period: str | None = Field(default=None,max_length=10)
def query_card(card, connection, project_id):
def query_card(card, connection, project_id, demo_samples=None, exact_decimals=False):
if connection.get("type") != "demo":
try: return execute(connection,card.get("sql",""),project_id)
try: return execute(connection,card.get("sql",""),project_id, exact_decimals=exact_decimals)
except ImportError: raise ValueError("数据库驱动未安装,请安装 backend/requirements.txt 中的依赖")
with closing(sqlite3.connect(":memory:")) as conn:
for table, rows in SAMPLES.items():
for table, rows in (SAMPLES if demo_samples is None else demo_samples).items():
keys=list(rows[0])
conn.execute(f"CREATE TABLE {table} ({','.join(keys)})")
conn.executemany(f"INSERT INTO {table} VALUES ({','.join('?' for _ in keys)})", [tuple(r[k] for k in keys) for r in rows])
@@ -254,6 +254,7 @@ def query_card(card, connection, project_id):
@router.post("/cards/{card_id}/test")
def test_card(card_id:str, payload:RunInput,request:Request):
check_project(request,payload.project_id)
with _db() as db:
card=next((x for x in _all(db,"cards") if x["id"]==card_id),None)
if not card: raise HTTPException(404,"数据卡不存在")
@@ -272,7 +273,7 @@ def test_card(card_id:str, payload:RunInput,request:Request):
def history(request:Request):
user=request.state.identity
with _db() as db: results=[json.loads(r[0]) for r in db.execute("SELECT body FROM runs ORDER BY rowid DESC")]
results=[r for r in results if user["role"]!="manager" or "*" in user.get("projects",[]) or r["project_id"] in user.get("projects",[])]
results=[r for r in results if "*" in user.get("projects",[]) or r["project_id"] in user.get("projects",[])]
return [visible_result(r,user) for r in results[:30]]
class ReviewInput(BaseModel):
+4 -4
View File
@@ -6,9 +6,9 @@ from decimal import Decimal
import sqlite3
def normalize(value):
def normalize(value, exact_decimals=False):
if isinstance(value, (date, datetime)): return value.isoformat()
if isinstance(value, Decimal): return float(value)
if isinstance(value, Decimal): return str(value) if exact_decimals else float(value)
if isinstance(value, bytes): raise ValueError("查询包含二进制数据,请返回附件索引而非文件内容")
return value
@@ -34,7 +34,7 @@ def validate_query(sql, allowed_tables, dialect):
raise ValueError("查询使用了未授权的库表或视图")
def execute(connection, sql, project_id):
def execute(connection, sql, project_id, exact_decimals=False):
from sqlalchemy import create_engine, text
from sqlalchemy.engine import URL
kind = connection.get("type")
@@ -84,7 +84,7 @@ def execute(connection, sql, project_id):
db.exec_driver_sql(f"SET SESSION MAX_EXECUTION_TIME = {seconds*1000}")
rows = db.execute(text(sql), {"project_id": project_id}).mappings().fetchmany(max_rows+1)
if len(rows)>max_rows: raise ValueError(f"查询结果超过 {max_rows} 行,请缩小范围")
return [{k:normalize(v) for k,v in row.items()} for row in rows]
return [{k:normalize(v, exact_decimals) for k,v in row.items()} for row in rows]
except ValueError:
raise
except Exception:
+1 -1
View File
@@ -16,7 +16,7 @@ def projects(request: Request):
for obj in existing_rows(db, table):
project_id = obj["project_id"]
user = request.state.identity
if user["role"] == "manager" and "*" not in user.get("projects", []) and project_id not in user.get("projects", []):
if "*" not in user.get("projects", []) and project_id not in user.get("projects", []):
continue
entry = objects.setdefault(project_id, {"project_id": project_id, "risk": None, "report": None, "coordination": None, "collateral": None})
if entry[key] is not None: