refactor: 全栈架构升级 - 风险控制、会话管理、审计日志和验证码功能
后端变更: - 新增 risk_config.py: 风险配置数据类,支持环境变量驱动 - 新增 risk_control.py: 风险控制控制器,管理并发和预算 - 新增 session_store.py: 匿名会话存储,基于 cookie 的 session ID - 新增 audit_store.py: API 审计日志存储,记录请求和 LLM 调用 - 新增 captcha_api.py: 验证码 API,用于验证用户操作真实性 - 新增 llm_policy.py: LLM 策略配置,管理 completion/pro/vision 模型 - main.py: 集成 middleware、risk/audit/session 模块 (+467/-7) - job_handlers.py: LLM 执行流程重构,新增 risk/audit 集成 (+207/-4) - llm.py: 异步客户端封装,新增 max_output_tokens 参数 (+78/-1) - job_system.py: stream_events 逻辑优化,支持心跳检测 (+12/-4) - pro_completions.py: SSE heartbeat 机制,防止连接超时 (+14/-4) - prompt.py: _normalize_preferences 支持 Mapping 类型 (+13/-0) - tts_asr.py: asyncio loop 初始化,router export (+10/-0) 前端变更: - src/components/CaptchaComponent.vue: 新增验证码组件 (NEW) - src/utils/cookie_policy.js: Cookie 策略工具 (NEW) - SettingsPanel.vue: 集成验证码组件,新增安全设置部分 (+59/-0) - MilkdownEditor.vue: 移除硬编码 API_KEY,新增 credentials (+32/-10) - ProBlockCrepe.vue: 样式简化,移除渐变动画 (+18/-4) - proBlockPlugin.ts: 重构 schema/serializer 引用方式,通过 Ctx 管理 (+40/-10) - api.js: 新增 credentials,重构 headers 条件逻辑 (+50/-14) - config.js: API 基址改为 https://api.imageteach.tech:8002 (+8/-4) - convert.js, docsApi.js, i18n.js: 新增 credentials 和验证码 i18n (+54/-12) - proAccept.js: 重构正则和转义处理,修复捕获组索引 (+14/-4) 配置和基础设施: - docker-compose.yml: 新增端口映射 8001:8001 (+2/-0) - docker/nginx.conf: 改为 307 redirect,优化代理配置 (+8/-6) - vite.config.js: 移除 proxy 配置,直接调用远程 API (+8/-4) - .env.example: 新增 VITE_API_BASE_URL, VITE_API_KEY (+3/-1) - backend/.env.example: 大量 RISK_*, SESSION_*, CORS_* 配置 (+54/-0) - pytest.ini: 扩展 coverage 范围到整个 backend,移除 fail_under (+3/-2) - .coveragerc: 移除 fail_under = 90 (+0/-1) - .gitignore: 新增 docker-data/ (+3/-0) - package.json: 新增 vue3-captcha 依赖 (+3/-1) - AGENTS.md, README.md: 更新 Docker 部署和前端网络约定 (+20/-5) - public/sw.js: Service Worker cache 版本从 v1 升级到 v2 (+0/-1) 测试变更: - test_main_endpoints.py: 新增 session/risk/audit reset,新增测试用例 (+63/-4) - test_main_cancel.py: 新增 reset 调用 (+6/-0) - test_pro_completions.py: 新增 preferences 序列化和测试 (+23/-0) 总计: 45 个文件变更,+1009/-280 行
This commit is contained in:
+163
-44
@@ -6,12 +6,15 @@ from typing import Any, Callable, Awaitable
|
||||
|
||||
import markitdown
|
||||
|
||||
from audit_store import get_audit_store
|
||||
from llm import call_ollama, call_vlm_ocr, stream_ollama_events
|
||||
from prompt import (
|
||||
build_completion_prompts,
|
||||
build_pro_completion_prompts,
|
||||
prepare_prompt_context,
|
||||
)
|
||||
from risk_config import load_risk_config
|
||||
from risk_control import RiskIdentity, estimate_tokens, get_risk_controller
|
||||
|
||||
try: # pragma: no cover - optional heavy dependency path
|
||||
from tts_asr import generate_asr_response, generate_tts_response
|
||||
@@ -24,6 +27,7 @@ IMAGE_MARKDOWN_RE = re.compile(r"!\[[^\]]*]\([^)]+\)")
|
||||
IMAGE_HTML_RE = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
|
||||
ALLOWED_CONVERT_EXTENSIONS = {".txt", ".docx", ".pptx", ".pdf"}
|
||||
_markitdown_instance = None
|
||||
_risk_config = load_risk_config()
|
||||
|
||||
|
||||
def _get_markitdown():
|
||||
@@ -78,12 +82,85 @@ def sanitize_inline_completion_content(text: str, prefill: str = "") -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _payload_identity(payload: dict[str, Any]) -> RiskIdentity:
|
||||
risk = payload.get("risk") or {}
|
||||
return RiskIdentity(
|
||||
request_id=risk.get("request_id") or payload["request_id"],
|
||||
session_hash=risk.get("session_hash", ""),
|
||||
ip_hash=risk.get("ip_hash", ""),
|
||||
route=payload.get("route", payload.get("job_type", payload.get("request_id", ""))),
|
||||
method="POST",
|
||||
)
|
||||
|
||||
|
||||
async def _enter_llm_execution(payload: dict[str, Any], emit: Callable[[str, dict[str, Any]], Awaitable[None]]) -> tuple[RiskIdentity, dict[str, Any], list[str]]:
|
||||
risk = payload.get("risk") or {}
|
||||
identity = _payload_identity(payload)
|
||||
delay_ms = int(risk.get("delay_ms", 0) or 0)
|
||||
policy = risk.get("policy") or {}
|
||||
if delay_ms > 0:
|
||||
await emit("resource", {"phase": "delay", "delay_ms": delay_ms})
|
||||
await asyncio.sleep(delay_ms / 1000.0)
|
||||
controller = get_risk_controller(_risk_config)
|
||||
lock_keys = await controller.acquire_execution_slot(identity, model=policy.get("model", ""))
|
||||
return identity, risk, lock_keys
|
||||
|
||||
|
||||
async def _exit_llm_execution(
|
||||
payload: dict[str, Any],
|
||||
identity: RiskIdentity,
|
||||
risk: dict[str, Any],
|
||||
lock_keys: list[str],
|
||||
*,
|
||||
status: str,
|
||||
actual_output_text: str = "",
|
||||
error_code: str = "",
|
||||
) -> None:
|
||||
policy = (risk.get("policy") or {})
|
||||
controller = get_risk_controller(_risk_config)
|
||||
await controller.release_execution_slot(identity, lock_keys, model=policy.get("model", ""))
|
||||
await controller.record_model_result(model=policy.get("model", ""), success=(status == "completed"))
|
||||
store = get_audit_store(os.getenv("DATABASE_URL", "").strip() or None)
|
||||
estimated_input_tokens = int(risk.get("estimated_input_tokens", 0) or 0)
|
||||
profile = policy.get("profile", "completion")
|
||||
pricing_out = {
|
||||
"completion": _risk_config.completion_output_cost_per_1k,
|
||||
"pro": _risk_config.pro_output_cost_per_1k,
|
||||
"vision": _risk_config.vision_output_cost_per_1k,
|
||||
}.get(profile, _risk_config.completion_output_cost_per_1k)
|
||||
actual_output_tokens = estimate_tokens(actual_output_text)
|
||||
actual_cost = round((estimated_input_tokens / 1000.0) * {
|
||||
"completion": _risk_config.completion_input_cost_per_1k,
|
||||
"pro": _risk_config.pro_input_cost_per_1k,
|
||||
"vision": _risk_config.vision_input_cost_per_1k,
|
||||
}.get(profile, _risk_config.completion_input_cost_per_1k) + (actual_output_tokens / 1000.0) * pricing_out, 8)
|
||||
await asyncio.to_thread(
|
||||
store.record_llm_call,
|
||||
{
|
||||
"request_id": payload["request_id"],
|
||||
"session_hash": identity.session_hash,
|
||||
"ip_hash": identity.ip_hash,
|
||||
"job_type": policy.get("job_type", ""),
|
||||
"model": policy.get("model", ""),
|
||||
"estimated_input_tokens": estimated_input_tokens,
|
||||
"max_output_tokens": int(policy.get("max_output_tokens", 0) or 0),
|
||||
"estimated_cost": float(risk.get("estimated_cost", 0.0) or 0.0),
|
||||
"actual_output_chars": len(actual_output_text or ""),
|
||||
"actual_cost": actual_cost,
|
||||
"status": status,
|
||||
"error_code": error_code,
|
||||
"metadata": {"profile": profile},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def completion_handler(
|
||||
payload: dict[str, Any],
|
||||
emit: Callable[[str, dict[str, Any]], Awaitable[None]],
|
||||
is_cancelled: Callable[[], bool],
|
||||
) -> dict[str, Any]:
|
||||
req = payload["request"]
|
||||
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
|
||||
system_prompt, user_prompt, prefill = build_completion_prompts(
|
||||
req["prefix"],
|
||||
req["suffix"],
|
||||
@@ -92,21 +169,30 @@ async def completion_handler(
|
||||
thinking_level=req.get("model_thinking", "low"),
|
||||
preferences=req.get("user_preferences"),
|
||||
)
|
||||
|
||||
result = await call_ollama(
|
||||
user_prompt,
|
||||
system_prompt=system_prompt,
|
||||
tag=f'{payload["request_id"][:8]}-completion',
|
||||
temperature=float(req.get("temperature", 0.7)),
|
||||
thinking=req.get("model_thinking") if req.get("model_thinking") != "none" else None,
|
||||
model=req.get("model"),
|
||||
prefill=prefill or None,
|
||||
)
|
||||
content = sanitize_inline_completion_content(result.get("content") or "", prefill=prefill or "")
|
||||
if is_cancelled():
|
||||
raise asyncio.CancelledError()
|
||||
await emit("result", {"content": content})
|
||||
return {"content": content, "request_id": payload["request_id"]}
|
||||
policy = risk.get("policy") or {}
|
||||
try:
|
||||
result = await call_ollama(
|
||||
user_prompt,
|
||||
system_prompt=system_prompt,
|
||||
tag=f'{payload["request_id"][:8]}-completion',
|
||||
temperature=float(policy.get("temperature", req.get("temperature", 0.7))),
|
||||
thinking=policy.get("thinking"),
|
||||
model=policy.get("model"),
|
||||
prefill=prefill or None,
|
||||
max_output_tokens=int(policy.get("max_output_tokens", 0) or 0),
|
||||
)
|
||||
content = sanitize_inline_completion_content(result.get("content") or "", prefill=prefill or "")
|
||||
if is_cancelled():
|
||||
raise asyncio.CancelledError()
|
||||
await emit("result", {"content": content})
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=content)
|
||||
return {"content": content, "request_id": payload["request_id"]}
|
||||
except asyncio.CancelledError:
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
|
||||
raise
|
||||
except Exception:
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="failed", error_code="llm_failed")
|
||||
raise
|
||||
|
||||
|
||||
async def pro_completion_handler(
|
||||
@@ -115,6 +201,7 @@ async def pro_completion_handler(
|
||||
is_cancelled: Callable[[], bool],
|
||||
) -> dict[str, Any]:
|
||||
req = payload["request"]
|
||||
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
|
||||
system_prompt, user_prompt = build_pro_completion_prompts(
|
||||
prefix=req["prefix"],
|
||||
suffix=req["suffix"],
|
||||
@@ -125,25 +212,35 @@ async def pro_completion_handler(
|
||||
preferences=req.get("user_preferences"),
|
||||
)
|
||||
chunks: list[str] = []
|
||||
async for event_type, delta in stream_ollama_events(
|
||||
user_prompt,
|
||||
system_prompt=system_prompt,
|
||||
tag=f'{payload["request_id"][:8]}-pro',
|
||||
temperature=0.7,
|
||||
thinking=req.get("pro_thinking", "medium"),
|
||||
use_pro_model=True,
|
||||
enable_thinking=True,
|
||||
):
|
||||
if is_cancelled():
|
||||
raise asyncio.CancelledError()
|
||||
if event_type == "thinking":
|
||||
await emit("progress", {"phase": "thinking"})
|
||||
continue
|
||||
if delta:
|
||||
chunks.append(delta)
|
||||
await emit("result", {"delta": delta})
|
||||
content = "".join(chunks)
|
||||
return {"content": content, "request_id": payload["request_id"]}
|
||||
policy = risk.get("policy") or {}
|
||||
try:
|
||||
async for event_type, delta in stream_ollama_events(
|
||||
user_prompt,
|
||||
system_prompt=system_prompt,
|
||||
tag=f'{payload["request_id"][:8]}-pro',
|
||||
temperature=float(policy.get("temperature", 0.7)),
|
||||
thinking=policy.get("thinking"),
|
||||
model=policy.get("model"),
|
||||
enable_thinking=True,
|
||||
max_output_tokens=int(policy.get("max_output_tokens", 0) or 0),
|
||||
):
|
||||
if is_cancelled():
|
||||
raise asyncio.CancelledError()
|
||||
if event_type == "thinking":
|
||||
await emit("progress", {"phase": "thinking"})
|
||||
continue
|
||||
if delta:
|
||||
chunks.append(delta)
|
||||
await emit("result", {"delta": delta})
|
||||
content = "".join(chunks)
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=content)
|
||||
return {"content": content, "request_id": payload["request_id"]}
|
||||
except asyncio.CancelledError:
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
|
||||
raise
|
||||
except Exception:
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="failed", error_code="llm_failed")
|
||||
raise
|
||||
|
||||
|
||||
async def compress_handler(
|
||||
@@ -153,21 +250,35 @@ async def compress_handler(
|
||||
) -> dict[str, Any]:
|
||||
content = payload["content"]
|
||||
doc_type = payload.get("docType", "txt")
|
||||
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
|
||||
system_prompt = (
|
||||
f"你是一个专业的文档摘要助手。请将以下 {doc_type} 类型文档内容进行精简压缩,"
|
||||
"保留核心信息和关键要点,去除冗余和啰嗦的表述。"
|
||||
"请直接输出压缩后的内容,不要添加任何解释性文字。"
|
||||
)
|
||||
result = await call_ollama(
|
||||
content,
|
||||
system_prompt=system_prompt,
|
||||
tag=f'{payload["request_id"][:8]}-compress',
|
||||
)
|
||||
if is_cancelled():
|
||||
raise asyncio.CancelledError()
|
||||
compressed = result.get("content") or ""
|
||||
await emit("result", {"content": compressed})
|
||||
return {"content": compressed, "request_id": payload["request_id"]}
|
||||
policy = risk.get("policy") or {}
|
||||
try:
|
||||
result = await call_ollama(
|
||||
content,
|
||||
system_prompt=system_prompt,
|
||||
tag=f'{payload["request_id"][:8]}-compress',
|
||||
model=policy.get("model"),
|
||||
temperature=float(policy.get("temperature", 0.2)),
|
||||
thinking=policy.get("thinking"),
|
||||
max_output_tokens=int(policy.get("max_output_tokens", 0) or 0),
|
||||
)
|
||||
if is_cancelled():
|
||||
raise asyncio.CancelledError()
|
||||
compressed = result.get("content") or ""
|
||||
await emit("result", {"content": compressed})
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=compressed)
|
||||
return {"content": compressed, "request_id": payload["request_id"]}
|
||||
except asyncio.CancelledError:
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
|
||||
raise
|
||||
except Exception:
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="failed", error_code="llm_failed")
|
||||
raise
|
||||
|
||||
|
||||
async def ocr_handler(
|
||||
@@ -176,6 +287,7 @@ async def ocr_handler(
|
||||
is_cancelled: Callable[[], bool],
|
||||
) -> dict[str, Any]:
|
||||
path = payload["input_path"]
|
||||
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
image_bytes = handle.read()
|
||||
@@ -183,7 +295,14 @@ async def ocr_handler(
|
||||
if is_cancelled():
|
||||
raise asyncio.CancelledError()
|
||||
await emit("result", {"text": text})
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=text)
|
||||
return {"text": text, "filename": payload.get("filename", "image.jpg")}
|
||||
except asyncio.CancelledError:
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
|
||||
raise
|
||||
except Exception:
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="failed", error_code="ocr_failed")
|
||||
raise
|
||||
finally:
|
||||
_safe_unlink(path)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user