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