chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
6
ai-service/.dockerignore
Normal file
6
ai-service/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
sample_inventory.xlsx
|
||||
*.md
|
||||
11
ai-service/Dockerfile
Normal file
11
ai-service/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
ARG BASE_REGISTRY=docker.m.daocloud.io/library
|
||||
FROM ${BASE_REGISTRY}/python:3.12-slim-bookworm
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir \
|
||||
-i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||
--trusted-host pypi.tuna.tsinghua.edu.cn \
|
||||
-r requirements.txt
|
||||
COPY . .
|
||||
EXPOSE 8001
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
1807
ai-service/app.py
Normal file
1807
ai-service/app.py
Normal file
File diff suppressed because it is too large
Load Diff
83
ai-service/demo_fixtures.py
Normal file
83
ai-service/demo_fixtures.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""本地演示/测试素材:供控制台一键预填需求与上传文件。
|
||||
|
||||
目录默认:仓库 test/(可用 DEMO_FIXTURES_DIR 覆盖)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def fixtures_root() -> Path:
|
||||
raw = (os.getenv("DEMO_FIXTURES_DIR") or "").strip()
|
||||
if raw:
|
||||
return Path(raw).expanduser().resolve()
|
||||
return (_ROOT / "test").resolve()
|
||||
|
||||
|
||||
# 默认演示包:沉降观测(行业示例,仅测试用)
|
||||
DEFAULT_PACK = {
|
||||
"id": "settlement_demo",
|
||||
"label": "沉降观测演示包(test/)",
|
||||
"prompt": "prompts/prompt_1.txt",
|
||||
"data": "data/settlement.xlsx",
|
||||
"images": ["refs/board_a.png", "refs/board_b.png"],
|
||||
"layouts": ["refs/dashboard.html", "refs/nav.html"],
|
||||
"slug": "settlement_observation_system",
|
||||
}
|
||||
|
||||
|
||||
def _safe_file(rel: str) -> Path:
|
||||
root = fixtures_root()
|
||||
rel = (rel or "").replace("\\", "/").lstrip("/")
|
||||
if not rel or ".." in rel.split("/"):
|
||||
raise HTTPException(400, "非法路径")
|
||||
path = (root / rel).resolve()
|
||||
try:
|
||||
path.relative_to(root)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, "越界路径") from e
|
||||
if not path.is_file():
|
||||
raise HTTPException(404, f"文件不存在: {rel}")
|
||||
return path
|
||||
|
||||
|
||||
def fixtures_manifest() -> dict:
|
||||
root = fixtures_root()
|
||||
pack = dict(DEFAULT_PACK)
|
||||
pack["root"] = str(root)
|
||||
pack["available"] = root.is_dir()
|
||||
|
||||
def ok(rel: str) -> bool:
|
||||
try:
|
||||
return _safe_file(rel).is_file()
|
||||
except HTTPException:
|
||||
return False
|
||||
|
||||
pack["files"] = {
|
||||
"prompt": {"path": pack["prompt"], "ok": ok(pack["prompt"])},
|
||||
"data": {"path": pack["data"], "ok": ok(pack["data"])},
|
||||
"images": [{"path": p, "ok": ok(p)} for p in pack["images"]],
|
||||
"layouts": [{"path": p, "ok": ok(p)} for p in pack["layouts"]],
|
||||
}
|
||||
return pack
|
||||
|
||||
|
||||
def read_prompt_text() -> str:
|
||||
return _safe_file(DEFAULT_PACK["prompt"]).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def file_response(rel: str) -> FileResponse:
|
||||
path = _safe_file(rel)
|
||||
ctype, _ = mimetypes.guess_type(str(path))
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=ctype or "application/octet-stream",
|
||||
filename=path.name,
|
||||
)
|
||||
45
ai-service/etc/README.md
Normal file
45
ai-service/etc/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# AI / LLM 配置说明
|
||||
|
||||
供应商、模型列表、默认通道写在 **`ai-service/etc/llm.yaml`**,**不要写在 Python 代码里**。
|
||||
|
||||
密钥仍放项目根 **`.env`**(`DEEPSEEK_API_KEY` 等),配置文件只引用环境变量名(`api_key_env`)。
|
||||
|
||||
## 文件
|
||||
|
||||
| 路径 | 作用 |
|
||||
|------|------|
|
||||
| `ai-service/etc/llm.yaml` | 供应商、模型、视觉默认、别名 |
|
||||
| 项目根 `.env` | API Key、可选 `LLM_PROVIDER` / `VISION_*` 覆盖 |
|
||||
| `AI_CONFIG_PATH` / `LLM_CONFIG_PATH` | 自定义配置文件绝对路径 |
|
||||
|
||||
Docker:`docker-compose.yml` 将 `llm.yaml` 挂载到 `/app/etc/llm.yaml`。
|
||||
|
||||
## 改配置后
|
||||
|
||||
```bash
|
||||
./reload-config.sh ai
|
||||
# 或
|
||||
docker compose up -d --force-recreate ai
|
||||
```
|
||||
|
||||
进程内配置有缓存;重启 `ai` 容器后生效。
|
||||
|
||||
## 增删供应商
|
||||
|
||||
在 `providers:` 下增加一段即可,例如:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
openai:
|
||||
label: OpenAI
|
||||
base_url: https://api.openai.com/v1
|
||||
api_key_env: OPENAI_API_KEY
|
||||
default_model: gpt-4o-mini
|
||||
models:
|
||||
- id: gpt-4o-mini
|
||||
label: gpt-4o-mini
|
||||
supports_vision: true
|
||||
json_mode: true
|
||||
```
|
||||
|
||||
并在 `.env` 增加 `OPENAI_API_KEY=...`,控制台「模型」列表会从 `/ai/api/v1/llm/providers` 自动带出。
|
||||
88
ai-service/etc/llm.yaml
Normal file
88
ai-service/etc/llm.yaml
Normal file
@@ -0,0 +1,88 @@
|
||||
# AI / LLM 供应商配置(独立文件,勿把密钥写进本文件)
|
||||
# 密钥放项目根 .env:DEEPSEEK_API_KEY / MINIMAX_API_KEY / DASHSCOPE_API_KEY
|
||||
# 覆盖路径:环境变量 AI_CONFIG_PATH 或 LLM_CONFIG_PATH
|
||||
# 改本文件后:./reload-config.sh ai
|
||||
|
||||
default_provider: deepseek
|
||||
|
||||
# 视觉通道(截图理解 / 还原度打分);未配密钥时自动跳过看图
|
||||
vision:
|
||||
provider: dashscope
|
||||
model: qwen3.6-plus
|
||||
# 别名 → 正式 provider id
|
||||
aliases:
|
||||
qwen: dashscope
|
||||
aliyun: dashscope
|
||||
tongyi: dashscope
|
||||
|
||||
# 文本模型别名(请求里写 qwen 时落到 dashscope)
|
||||
aliases:
|
||||
qwen: dashscope
|
||||
aliyun: dashscope
|
||||
tongyi: dashscope
|
||||
|
||||
# 视觉模型名启发式(用于判断传入的 model 是否像视觉模型)
|
||||
vision_model_hints:
|
||||
- vl
|
||||
- vision
|
||||
- qwen3
|
||||
- qwen-vl
|
||||
- qwen-plus
|
||||
- gpt-4o
|
||||
- gemini
|
||||
|
||||
providers:
|
||||
heuristic:
|
||||
label: 本地启发式(不调大模型)
|
||||
base_url: ""
|
||||
api_key_env: ""
|
||||
default_model: ""
|
||||
models: []
|
||||
supports_vision: false
|
||||
json_mode: false
|
||||
|
||||
deepseek:
|
||||
label: DeepSeek
|
||||
base_url: https://api.deepseek.com/v1
|
||||
api_key_env: DEEPSEEK_API_KEY
|
||||
default_model: deepseek-chat
|
||||
models:
|
||||
- id: deepseek-chat
|
||||
label: deepseek-chat
|
||||
- id: deepseek-reasoner
|
||||
label: deepseek-reasoner
|
||||
supports_vision: false
|
||||
json_mode: true
|
||||
|
||||
minimax:
|
||||
label: MiniMax
|
||||
base_url: https://api.minimaxi.com/v1
|
||||
api_key_env: MINIMAX_API_KEY
|
||||
default_model: MiniMax-Text-01
|
||||
models:
|
||||
- id: MiniMax-Text-01
|
||||
label: MiniMax-Text-01
|
||||
- id: MiniMax-M2.5
|
||||
label: MiniMax-M2.5
|
||||
- id: abab6.5s-chat
|
||||
label: abab6.5s-chat
|
||||
supports_vision: false
|
||||
json_mode: false
|
||||
|
||||
dashscope:
|
||||
label: 通义千问 (DashScope)
|
||||
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
# 可用环境变量 DASHSCOPE_BASE_URL 覆盖 base_url
|
||||
api_key_env: DASHSCOPE_API_KEY
|
||||
default_model: qwen3.6-plus
|
||||
models:
|
||||
- id: qwen3.6-plus
|
||||
label: qwen3.6-plus(视觉)
|
||||
- id: qwen-vl-plus
|
||||
label: qwen-vl-plus
|
||||
- id: qwen-vl-max
|
||||
label: qwen-vl-max
|
||||
- id: qwen-plus
|
||||
label: qwen-plus
|
||||
supports_vision: true
|
||||
json_mode: false
|
||||
930
ai-service/fidelity_loop.py
Normal file
930
ai-service/fidelity_loop.py
Normal file
@@ -0,0 +1,930 @@
|
||||
"""截图还原迭代闭环(通用,不绑定行业)。
|
||||
|
||||
流程:
|
||||
1. 视觉模型从参考截图生成结构化提示(上游已完成)
|
||||
2. 代码模型生成/润色蓝图(上游已完成)
|
||||
3. 将「当前蓝图将渲染的 UI 计划」与参考截图交给视觉模型打分给差异
|
||||
4. 代码模型按差异改蓝图
|
||||
5. 重复直至 score>=目标 或达到最大轮次
|
||||
|
||||
可选:若环境有 playwright,用蓝图 meta 渲染简易预览 HTML 截图再对比(更接近真截图)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from llm import (
|
||||
_api_key,
|
||||
enhance_blueprint_with_llm,
|
||||
resolve_vision_provider,
|
||||
)
|
||||
from generate_log import glog
|
||||
|
||||
|
||||
def fidelity_target() -> int:
|
||||
try:
|
||||
return max(50, min(100, int(os.getenv("FIDELITY_TARGET", "95"))))
|
||||
except ValueError:
|
||||
return 95
|
||||
|
||||
|
||||
def fidelity_max_rounds() -> int:
|
||||
try:
|
||||
return max(1, min(8, int(os.getenv("FIDELITY_MAX_ROUNDS", "4"))))
|
||||
except ValueError:
|
||||
return 4
|
||||
|
||||
|
||||
def fidelity_enabled() -> bool:
|
||||
v = (os.getenv("FIDELITY_LOOP") or "1").strip().lower()
|
||||
return v not in {"0", "false", "off", "no"}
|
||||
|
||||
|
||||
def ui_plan_from_draft(draft: dict[str, Any]) -> str:
|
||||
"""把蓝图里会影响展示的字段压成「将渲染的界面计划」,供视觉对比。"""
|
||||
meta = draft.get("meta") or {}
|
||||
ui = meta.get("ui") or {}
|
||||
pages = draft.get("pages") or []
|
||||
dash = next((p for p in pages if isinstance(p, dict) and p.get("type") == "dashboard"), None)
|
||||
widgets = ((dash or {}).get("layout") or {}).get("widgets") or []
|
||||
|
||||
lines = [
|
||||
"【当前蓝图将渲染的界面计划 · 非像素截图,但须与参考图文案/结构一致】",
|
||||
f"ui_preset: {meta.get('ui_preset') or ''}",
|
||||
f"平台抬头 platform_title: {meta.get('platform_title') or ui.get('platform_title') or ''}",
|
||||
f"平台英文 platform_subtitle: {ui.get('platform_subtitle') or ''}",
|
||||
f"系统名 name: {meta.get('name') or ''}",
|
||||
f"壳链 shell_links: {'、'.join(ui.get('shell_links') or [])}",
|
||||
f"浮条 float_actions: {'、'.join(ui.get('float_actions') or [])}",
|
||||
f"导航 nav_items: {'、'.join(ui.get('nav_items') or [])}",
|
||||
f"工程/业务上下文 project_context: {meta.get('project_context') or ui.get('project_context') or ''}",
|
||||
f"统计标签: {ui.get('stats_left_label') or ''} / {ui.get('stats_right_label') or ''}",
|
||||
f"单选 filter_radios: {'、'.join(ui.get('filter_radios') or ui.get('section_options') or [])}",
|
||||
f"单选字段 radio_field: {ui.get('radio_field') or ''}",
|
||||
f"下拉 select_label/select_field: {ui.get('select_label') or ''} / {ui.get('select_field') or ''}",
|
||||
f"提示 filter_hint: {ui.get('filter_hint') or ''}",
|
||||
f"侧标 chart/strip/table: {ui.get('chart_side_label') or ''} | {ui.get('strip_side_label') or ''} | {ui.get('table_side_label') or ''}",
|
||||
f"表标题 table_title: {ui.get('table_title') or ''}",
|
||||
f"表头 table_headers: {'、'.join(ui.get('table_headers') or [])}",
|
||||
f"chart_style: {ui.get('chart_style') or ''}",
|
||||
f"invert_y: {ui.get('invert_y')}",
|
||||
f"y_unit / y_axis_label: {ui.get('y_unit') or ''} / {ui.get('y_axis_label') or ''}",
|
||||
]
|
||||
legs = ui.get("legend_items") or []
|
||||
if legs:
|
||||
lines.append(
|
||||
"图例 legend_items: "
|
||||
+ "、".join(
|
||||
f"{x.get('label')}({x.get('color')})" if isinstance(x, dict) else str(x) for x in legs
|
||||
)
|
||||
)
|
||||
lines.append(f"看板页标题: {(dash or {}).get('title') or ''}")
|
||||
for w in widgets:
|
||||
if not isinstance(w, dict):
|
||||
continue
|
||||
lines.append(
|
||||
"widget: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": w.get("type"),
|
||||
"title": w.get("title"),
|
||||
"x_field": w.get("x_field"),
|
||||
"metrics": w.get("metrics"),
|
||||
"metric": w.get("metric"),
|
||||
"variant": w.get("variant"),
|
||||
"value_field": w.get("value_field"),
|
||||
"secondary_field": w.get("secondary_field"),
|
||||
"cycle_days": w.get("cycle_days"),
|
||||
"columns": w.get("columns"),
|
||||
"filter_field": w.get("filter_field"),
|
||||
"y_unit": w.get("y_unit"),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _platform_base() -> str:
|
||||
return (os.getenv("PLATFORM_BASE") or os.getenv("GATEWAY_BASE") or "http://127.0.0.1:8180").rstrip("/")
|
||||
|
||||
|
||||
def _web_base() -> str:
|
||||
return (os.getenv("WEB_BASE") or "http://127.0.0.1:5173").rstrip("/")
|
||||
|
||||
|
||||
def real_screen_enabled() -> bool:
|
||||
v = (os.getenv("FIDELITY_REAL_SCREEN") or "1").strip().lower()
|
||||
return v not in {"0", "false", "off", "no"}
|
||||
|
||||
|
||||
def _login_platform() -> str:
|
||||
user = os.getenv("FIDELITY_USER") or "demo"
|
||||
password = os.getenv("FIDELITY_PASSWORD") or "demo123"
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
r = client.post(
|
||||
f"{_platform_base()}/api/v1/auth/login",
|
||||
json={"username": user, "password": password},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _publish_draft(draft: dict[str, Any], token: str) -> str:
|
||||
"""发布蓝图并返回 slug。用独立 slug 避免误伤正式应用时可设 FIDELITY_SLUG_SUFFIX=_fidcheck。"""
|
||||
meta = draft.setdefault("meta", {})
|
||||
base_slug = str(meta.get("slug") or "app").strip() or "app"
|
||||
suffix = (os.getenv("FIDELITY_SLUG_SUFFIX") or "").strip()
|
||||
slug = f"{base_slug}{suffix}" if suffix else base_slug
|
||||
meta["slug"] = slug
|
||||
# 保证 apis.base_path 一致
|
||||
apis = draft.setdefault("apis", {})
|
||||
if isinstance(apis, dict):
|
||||
apis["base_path"] = f"/api/v1/apps/{slug}"
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
r = client.post(
|
||||
f"{_platform_base()}/api/v1/apps/{slug}/publish",
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
json={"blueprint": draft},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return slug
|
||||
|
||||
|
||||
def _capture_real_app_screenshot_impl(
|
||||
draft: dict[str, Any],
|
||||
*,
|
||||
preview_rows: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[bytes | None, str, str | None]:
|
||||
"""优先走草稿预览 /#/preview/{id}(无需登录/不必等发布);失败再回退公开应用页。"""
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright # type: ignore
|
||||
except Exception as e: # noqa: BLE001
|
||||
return None, f"无 playwright,无法截真页面: {e}", None
|
||||
|
||||
from preview_store import create_preview, get_preview
|
||||
|
||||
web = _web_base()
|
||||
out_dir = Path(os.getenv("FIDELITY_SHOT_DIR") or "").resolve() if os.getenv("FIDELITY_SHOT_DIR") else None
|
||||
goto_timeout = int(os.getenv("FIDELITY_GOTO_TIMEOUT_MS") or "45000")
|
||||
slug = str(((draft.get("meta") or {}).get("slug")) or "preview")
|
||||
preview_id = create_preview(
|
||||
json.loads(json.dumps(draft)),
|
||||
rows=preview_rows or [],
|
||||
)
|
||||
target = f"{web}/#/preview/{preview_id}"
|
||||
pack = get_preview(preview_id) or {
|
||||
"id": preview_id,
|
||||
"ok": True,
|
||||
"blueprint": draft,
|
||||
"rows": preview_rows or [],
|
||||
"resource": "records",
|
||||
}
|
||||
# 浏览器侧可能访问不到 AI;把预览包注入页面 + 拦截 /preview API
|
||||
inject_payload = {
|
||||
"ok": True,
|
||||
"id": preview_id,
|
||||
"blueprint": pack.get("blueprint") or draft,
|
||||
"rows": pack.get("rows") or preview_rows or [],
|
||||
"resource": pack.get("resource") or "records",
|
||||
}
|
||||
# 清掉本机过期登录态,避免 React 的 session-expired 把 /#/preview 踢回控制台
|
||||
inject_js = (
|
||||
"try{localStorage.removeItem('ajz_session');}catch(e){}"
|
||||
"window.__AJZ_PREVIEW__ = "
|
||||
+ json.dumps(inject_payload, ensure_ascii=False)
|
||||
+ ";"
|
||||
)
|
||||
|
||||
def _fmt_err(err: BaseException) -> str:
|
||||
msg = str(err).strip() or repr(err)
|
||||
return msg.replace("\n", " ")[:500]
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
try:
|
||||
page = browser.new_page(viewport={"width": 1600, "height": 1100})
|
||||
page.add_init_script(inject_js)
|
||||
|
||||
def _fulfill_preview(route): # type: ignore
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json; charset=utf-8",
|
||||
body=json.dumps(inject_payload, ensure_ascii=False).encode("utf-8"),
|
||||
)
|
||||
|
||||
page.route("**/api/v1/preview/**", _fulfill_preview)
|
||||
page.route("**/ai/api/v1/preview/**", _fulfill_preview)
|
||||
page.goto(target, wait_until="domcontentloaded", timeout=goto_timeout)
|
||||
# 再写一次,防止极端时序下 init 被覆盖
|
||||
page.evaluate(inject_js)
|
||||
try:
|
||||
page.wait_for_selector(
|
||||
".sf-shell-bar, .sf-root, .gen-app-ops, .gen-app, .sf-line-wrap",
|
||||
timeout=45000,
|
||||
)
|
||||
except Exception:
|
||||
snip = ""
|
||||
try:
|
||||
snip = page.locator("body").inner_text(timeout=2000)[:240]
|
||||
except Exception:
|
||||
snip = page.url
|
||||
raise RuntimeError(
|
||||
f"预览壳未出现(请确认 Web 已含 /#/preview 前端)。page={page.url} text={snip!r}"
|
||||
) from None
|
||||
# 仅当仍在登录台(无预览壳)时判定失败
|
||||
if page.locator("button").filter(has_text="登录").count() and not page.locator(
|
||||
".sf-shell-bar, .sf-root, .gen-app"
|
||||
).count():
|
||||
raise RuntimeError("预览页落到登录界面")
|
||||
page.wait_for_timeout(2500)
|
||||
if page.locator(".gen-app").count():
|
||||
png = page.locator(".gen-app").first.screenshot(type="png")
|
||||
else:
|
||||
png = page.screenshot(type="png", full_page=True)
|
||||
finally:
|
||||
browser.close()
|
||||
if out_dir:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
(out_dir / f"preview_{preview_id}.png").write_bytes(png)
|
||||
return (
|
||||
png,
|
||||
f"已截取草稿预览(/#/preview/{preview_id},注入数据无需再拉 AI)参与对比",
|
||||
slug,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# 回退:尝试发布后的公开应用页(仍不登录)
|
||||
try:
|
||||
token = _login_platform()
|
||||
pub_slug = _publish_draft(json.loads(json.dumps(draft)), token)
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
try:
|
||||
page = browser.new_page(viewport={"width": 1600, "height": 1100})
|
||||
page.add_init_script(
|
||||
"try{localStorage.removeItem('ajz_session');}catch(e){}"
|
||||
)
|
||||
page.goto(
|
||||
f"{web}/#/app/{pub_slug}",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=goto_timeout,
|
||||
)
|
||||
page.wait_for_selector(
|
||||
".sf-shell-bar, .sf-root, .gen-app-ops, .gen-app",
|
||||
timeout=45000,
|
||||
)
|
||||
if page.locator("button").filter(has_text="登录").count() and not page.locator(
|
||||
".sf-shell-bar, .sf-root, .gen-app"
|
||||
).count():
|
||||
raise RuntimeError("公开应用页仍显示登录")
|
||||
page.wait_for_timeout(2500)
|
||||
if page.locator(".gen-app").count():
|
||||
png = page.locator(".gen-app").first.screenshot(type="png")
|
||||
else:
|
||||
png = page.screenshot(type="png", full_page=True)
|
||||
finally:
|
||||
browser.close()
|
||||
if out_dir:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
(out_dir / f"real_{pub_slug}.png").write_bytes(png)
|
||||
return png, f"预览失败后回退公开应用页(slug={pub_slug}):{_fmt_err(e)}", pub_slug
|
||||
except Exception as e2: # noqa: BLE001
|
||||
return (
|
||||
None,
|
||||
f"真页面截图失败: preview={_fmt_err(e)}; fallback={_fmt_err(e2)}(目标 {target})",
|
||||
slug,
|
||||
)
|
||||
|
||||
|
||||
def capture_real_app_screenshot(
|
||||
draft: dict[str, Any],
|
||||
*,
|
||||
preview_rows: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[bytes | None, str, str | None]:
|
||||
"""
|
||||
草稿预览截图。Windows+uvicorn 下禁止同进程 Playwright,改为独立 shot_worker.py。
|
||||
"""
|
||||
if not real_screen_enabled():
|
||||
return None, "FIDELITY_REAL_SCREEN=0,跳过真页面截图", None
|
||||
|
||||
if (os.getenv("FIDELITY_SHOT_INPROCESS") or "").strip() in {"1", "true", "yes"}:
|
||||
return _capture_real_app_screenshot_impl(draft, preview_rows=preview_rows)
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from preview_store import create_preview
|
||||
|
||||
ai_dir = str(Path(__file__).resolve().parent)
|
||||
worker = Path(ai_dir) / "shot_worker.py"
|
||||
preview_id = create_preview(json.loads(json.dumps(draft)), rows=preview_rows or [])
|
||||
resource = "records"
|
||||
try:
|
||||
apis = draft.get("apis") or {}
|
||||
res0 = (apis.get("resources") or [None])[0]
|
||||
if isinstance(res0, dict) and res0.get("path"):
|
||||
resource = str(res0["path"]).lstrip("/")
|
||||
elif (draft.get("resources") or [{}])[0].get("name"):
|
||||
resource = str(draft["resources"][0]["name"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="ajz_shot_") as td:
|
||||
inp = Path(td) / "in.json"
|
||||
outp = Path(td) / "out.json"
|
||||
inp.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"draft": draft,
|
||||
"rows": preview_rows or [],
|
||||
"preview_id": preview_id,
|
||||
"resource": resource,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env["FIDELITY_SHOT_INPUT"] = str(inp)
|
||||
env["FIDELITY_SHOT_OUTPUT"] = str(outp)
|
||||
env.setdefault("WEB_BASE", _web_base())
|
||||
env.pop("PYTHONASYNCIODEBUG", None)
|
||||
creationflags = 0
|
||||
if sys.platform == "win32":
|
||||
# Detach from uvicorn's console/asyncio job so Playwright can spawn Chromium
|
||||
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) | getattr(
|
||||
subprocess, "CREATE_NEW_PROCESS_GROUP", 0
|
||||
)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(worker)],
|
||||
cwd=ai_dir,
|
||||
env=env,
|
||||
timeout=190,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
creationflags=creationflags,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return None, "真页面截图子进程超时(190s)", None
|
||||
err_tail = ((proc.stderr or "") + "\n" + (proc.stdout or "")).strip()[-600:]
|
||||
if not outp.is_file():
|
||||
return (
|
||||
None,
|
||||
f"真页面截图子进程无输出(code={proc.returncode}): {err_tail or 'no stderr'}",
|
||||
None,
|
||||
)
|
||||
try:
|
||||
data = json.loads(outp.read_text(encoding="utf-8"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
return None, f"真页面截图结果无法解析: {e}; stderr={err_tail}", None
|
||||
png_b64 = data.get("png_b64")
|
||||
png = base64.b64decode(png_b64) if png_b64 else None
|
||||
note = str(data.get("note") or "")
|
||||
if not note:
|
||||
note = f"shot_worker code={proc.returncode} stderr={err_tail or 'empty'}"
|
||||
elif not png:
|
||||
note = f"{note} | via=shot_worker code={proc.returncode}"
|
||||
if err_tail:
|
||||
note = f"{note} stderr={err_tail[:400]}"
|
||||
else:
|
||||
if "via=shot_worker" not in note:
|
||||
note = f"{note} | via=shot_worker"
|
||||
return png, note, data.get("slug")
|
||||
|
||||
|
||||
def _try_preview_screenshot(
|
||||
draft: dict[str, Any],
|
||||
*,
|
||||
preview_rows: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[bytes | None, str, bool]:
|
||||
"""截取预览/真页面。返回 (png, note, is_real_screen)。"""
|
||||
real, note, _slug = capture_real_app_screenshot(draft, preview_rows=preview_rows)
|
||||
if real:
|
||||
return real, note, True
|
||||
return None, note, False
|
||||
|
||||
|
||||
_COMPARE_PROMPT = """你是严格的 UI 还原验收员。必须「逐区对照真页面截图与参考图」,禁止因文案大致相似就给高分。
|
||||
|
||||
输入图顺序:
|
||||
- 图1起:参考原系统截图(可能多张,为同页不同展开态,合并理解)
|
||||
- 最后一张(若有):【系统真页面截图】——只以这张判定还原度,文字计划仅作辅助
|
||||
|
||||
重要:文案计划一致 ≠ 画面合格。真页面缺主图、主图被监督条替代、分区错位,必须大幅扣分。
|
||||
|
||||
## 必检分区(zone_checklist,每项 true/false;任一项 false → 对应 fails 必写)
|
||||
1) shell_header:顶栏平台名/系统名/右侧链接是否与原图一致
|
||||
2) nav:次级导航文案与高亮是否大体一致
|
||||
3) filter_bar:工程上下文、统计、单选、工点下拉、红色提示是否齐全且文案接近
|
||||
4) main_chart:是否存在「纵断面/主曲线图」——带坐标轴的折线或柱线混合大图(不是数字格、不是表格、不是监督条)
|
||||
5) chart_legend:主图上方/旁侧图例是否存在且系列名接近
|
||||
6) status_strip:按时测量监督是否为上灰下绿双段竖条(不是单色数字格矩阵、不是把主图区填成监督条)
|
||||
7) exceed_table:底部超限表标题与列是否接近
|
||||
8) side_labels:左侧竖标是否与三区(主图/监督/表)一一对应且正向可读
|
||||
9) float_dock:右侧浮条是否存在
|
||||
|
||||
## 硬性否决(任一条成立 → fails 必写,且 score 上限)
|
||||
1) 文字倒置/镜像 → score≤35
|
||||
2) 文字严重倾斜、重叠、裁切变形 → score≤45
|
||||
3) 主图未横向铺满(右侧大片空白)→ score≤55
|
||||
4) 顶栏/导航/筛选/侧标/表头/图例文案缺失或不一致 → score≤70
|
||||
5) 分区结构不对(缺侧标、缺状态条、缺浮条、侧标与内容错位)→ score≤60
|
||||
6) 图表形态不符,或真页面用监督条/数字格顶替主曲线图 → score≤50
|
||||
7) zone_checklist.main_chart=false → score≤50
|
||||
8) zone_checklist.status_strip=false 且原图有监督条 → score≤55
|
||||
|
||||
若没有「系统真页面截图」:
|
||||
- 不得判定 pass=true;score 最高 75
|
||||
- fails 必须含:「缺少真页面截图,无法验收字形方向/铺满/畸形」
|
||||
|
||||
## 禁止虚高
|
||||
- 不得因为「顶栏和表头大致对上」就把 score 抬到 ≥90
|
||||
- 缺主图或主图形态错误时,即使其它区都对,score 也不得超过 50
|
||||
- fails 为空仅当 zone_checklist 全部为 true 且肉眼无明显布局差异
|
||||
- 上一轮差异(若有)必须再次核对,未修复的必须继续写进 fails
|
||||
|
||||
上一轮未修复差异:
|
||||
{prev_fails}
|
||||
|
||||
输出【仅 JSON 对象】:
|
||||
{{
|
||||
"score": 0到100的整数,
|
||||
"pass": true或false,
|
||||
"fails": ["区名: 现象;应如何改正"],
|
||||
"passes": ["已对齐项"],
|
||||
"zone_checklist": {{
|
||||
"shell_header": true或false,
|
||||
"nav": true或false,
|
||||
"filter_bar": true或false,
|
||||
"main_chart": true或false,
|
||||
"chart_legend": true或false,
|
||||
"status_strip": true或false,
|
||||
"exceed_table": true或false,
|
||||
"side_labels": true或false,
|
||||
"float_dock": true或false
|
||||
}},
|
||||
"visual_checks": {{
|
||||
"text_upright": true或false,
|
||||
"chart_fills_width": true或false,
|
||||
"no_malformation": true或false
|
||||
}},
|
||||
"patch_hints": {{
|
||||
"meta.ui": {{}}
|
||||
}}
|
||||
}}
|
||||
pass 仅当:score>={target} 且 fails 为空 且 zone_checklist 全 true 且 visual_checks 三项均为 true 且已提供真页面截图。
|
||||
|
||||
【截图视觉摘录】
|
||||
{vision}
|
||||
|
||||
【当前蓝图界面计划】
|
||||
{plan}
|
||||
|
||||
【是否有系统真页面截图】
|
||||
{has_preview}
|
||||
"""
|
||||
|
||||
|
||||
_CRITICAL_ZONES = (
|
||||
("main_chart", "主图区: 真页面缺少与原图对应的折线/柱状主曲线图(或被监督条/数字格顶替)", 50),
|
||||
("status_strip", "监督条: 真页面缺少上灰下绿双段竖条监督区,或形态不符", 55),
|
||||
("side_labels", "侧标: 左侧竖标缺失、错位或与内容区不对应", 60),
|
||||
("exceed_table", "超限表: 底部超限测点表缺失或列/标题不符", 65),
|
||||
("filter_bar", "筛选条: 工程上下文/统计/单选/提示缺失或不一致", 70),
|
||||
("shell_header", "顶栏: 平台抬头/系统名/右侧链接缺失或不一致", 70),
|
||||
("nav", "导航: 次级导航文案或高亮与原图不一致", 70),
|
||||
("chart_legend", "图例: 主图图例缺失或系列名不符", 65),
|
||||
("float_dock", "浮条: 右侧浮条缺失", 75),
|
||||
)
|
||||
|
||||
|
||||
def _blueprint_structure_fails(draft: dict[str, Any]) -> list[tuple[str, int]]:
|
||||
"""蓝图结构硬校验:缺主图/监督条/表时不允许高分达标。"""
|
||||
pages = draft.get("pages") or []
|
||||
dash = next((p for p in pages if isinstance(p, dict) and p.get("type") == "dashboard"), None)
|
||||
widgets = ((dash or {}).get("layout") or {}).get("widgets") or []
|
||||
types = {str(w.get("type")) for w in widgets if isinstance(w, dict)}
|
||||
ui = ((draft.get("meta") or {}).get("ui") or {}) if isinstance(draft.get("meta"), dict) else {}
|
||||
out: list[tuple[str, int]] = []
|
||||
if not (types & {"line_chart", "bar_chart", "area_chart"}):
|
||||
out.append(("蓝图结构: dashboard 缺少折线/柱状主图 widget", 45))
|
||||
if "status_strip" not in types:
|
||||
out.append(("蓝图结构: dashboard 缺少 status_strip 监督条", 50))
|
||||
if "table" not in types:
|
||||
out.append(("蓝图结构: dashboard 缺少超限 table", 55))
|
||||
if not (ui.get("chart_side_label") and ui.get("strip_side_label") and ui.get("table_side_label")):
|
||||
out.append(("蓝图结构: meta.ui 三区侧标不全(chart/strip/table_side_label)", 60))
|
||||
if not (ui.get("legend_items") or ui.get("chart_legend")):
|
||||
# legend_items 更常见
|
||||
if not ui.get("legend_items"):
|
||||
out.append(("蓝图结构: meta.ui.legend_items 为空,主图图例难对齐", 65))
|
||||
return out
|
||||
|
||||
|
||||
def _enforce_score_caps(
|
||||
score: int,
|
||||
fails: list[str],
|
||||
checks: dict[str, Any],
|
||||
zones: dict[str, Any],
|
||||
*,
|
||||
has_real: bool,
|
||||
draft: dict[str, Any] | None,
|
||||
prev_fails: list[str] | None,
|
||||
) -> tuple[int, list[str], bool]:
|
||||
"""服务端强制扣分/否决,防止视觉模型虚高。"""
|
||||
fails = list(fails)
|
||||
if not has_real:
|
||||
score = min(score, 75)
|
||||
msg = "缺少真页面截图,无法验收字形方向/铺满/畸形"
|
||||
if msg not in fails:
|
||||
fails.append(msg)
|
||||
|
||||
for key, label, cap in (
|
||||
("text_upright", "竖排/正文存在倒置或不可正向阅读", 35),
|
||||
("chart_fills_width", "主图未横向铺满(右侧大片空白)", 55),
|
||||
("no_malformation", "存在明显畸形/变形", 45),
|
||||
):
|
||||
if checks.get(key) is False:
|
||||
score = min(score, cap)
|
||||
if not any(label[:6] in f for f in fails):
|
||||
fails.append(label)
|
||||
|
||||
for key, label, cap in _CRITICAL_ZONES:
|
||||
if zones.get(key) is False:
|
||||
score = min(score, cap)
|
||||
if not any(key in f or label[:4] in f for f in fails):
|
||||
fails.append(label)
|
||||
|
||||
# zone_checklist 未返回时,不默认信任满分
|
||||
if has_real and not zones:
|
||||
score = min(score, 80)
|
||||
msg = "视觉打分未返回 zone_checklist,按不完整验收处理"
|
||||
if msg not in fails:
|
||||
fails.append(msg)
|
||||
|
||||
if draft is not None:
|
||||
for label, cap in _blueprint_structure_fails(draft):
|
||||
score = min(score, cap)
|
||||
if label not in fails:
|
||||
fails.append(label)
|
||||
|
||||
# 上一轮硬伤若本轮 fails 空但分数暴涨,仍保守封顶(模型常漏检)
|
||||
if prev_fails and score >= 90:
|
||||
severe = [f for f in prev_fails if any(k in f for k in ("主图", "折线", "分区", "侧标", "监督", "形态"))]
|
||||
if severe and not fails:
|
||||
score = min(score, 70)
|
||||
fails.append(
|
||||
"上一轮结构性差异疑似未复核:"
|
||||
+ ";".join(severe[:3])
|
||||
+ "。请对照真页面复查,勿虚高满分"
|
||||
)
|
||||
|
||||
ok = score >= fidelity_target() and not fails and has_real
|
||||
if zones:
|
||||
ok = ok and all(zones.get(k) is True for k, _, _ in _CRITICAL_ZONES)
|
||||
for key in ("text_upright", "chart_fills_width", "no_malformation"):
|
||||
if checks.get(key) is False:
|
||||
ok = False
|
||||
return score, fails, ok
|
||||
|
||||
|
||||
def _parse_json_obj(text: str) -> dict[str, Any] | None:
|
||||
t = (text or "").strip()
|
||||
if t.startswith("```"):
|
||||
t = t.strip("`")
|
||||
if t.startswith("json"):
|
||||
t = t[4:].lstrip()
|
||||
try:
|
||||
obj = json.loads(t)
|
||||
return obj if isinstance(obj, dict) else None
|
||||
except json.JSONDecodeError:
|
||||
m = re.search(r"\{[\s\S]*\}", t)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(m.group(0))
|
||||
return obj if isinstance(obj, dict) else None
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def vision_score_fidelity(
|
||||
ref_images: list[tuple[str, bytes, str]],
|
||||
plan: str,
|
||||
vision_extract: str,
|
||||
preview_png: bytes | None,
|
||||
provider_id: str | None = None,
|
||||
model: str | None = None,
|
||||
*,
|
||||
is_real_screen: bool = False,
|
||||
draft: dict[str, Any] | None = None,
|
||||
prev_fails: list[str] | None = None,
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
notes: list[str] = []
|
||||
target = fidelity_target()
|
||||
pid, meta, model_name = resolve_vision_provider(provider_id, model)
|
||||
if not meta.get("supports_vision"):
|
||||
return {"score": 0, "pass": False, "fails": ["视觉模型不可用"], "passes": []}, [
|
||||
f"{meta['label']} 无视觉能力,跳过打分"
|
||||
]
|
||||
api_key = _api_key(meta)
|
||||
if not api_key:
|
||||
return {"score": 0, "pass": False, "fails": ["未配置视觉 API Key"], "passes": []}, [
|
||||
f"未配置 {meta.get('api_key_env')}"
|
||||
]
|
||||
|
||||
has_real = bool(preview_png) and is_real_screen
|
||||
prev_txt = ";".join((prev_fails or [])[:8]) if prev_fails else "(无,首轮)"
|
||||
content: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": _COMPARE_PROMPT.format(
|
||||
target=target,
|
||||
vision=vision_extract or "(无)",
|
||||
plan=plan,
|
||||
prev_fails=prev_txt,
|
||||
has_preview="有真页面截图(请逐区检查;缺主图不得高分)"
|
||||
if has_real
|
||||
else "无真页面截图(禁止判定达标)",
|
||||
),
|
||||
}
|
||||
]
|
||||
for name, raw, ctype in ref_images[:3]:
|
||||
if not ctype.startswith("image/"):
|
||||
ctype = "image/png"
|
||||
b64 = base64.b64encode(raw).decode("ascii")
|
||||
content.append({"type": "image_url", "image_url": {"url": f"data:{ctype};base64,{b64}"}})
|
||||
if preview_png and is_real_screen:
|
||||
b64 = base64.b64encode(preview_png).decode("ascii")
|
||||
content.append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}})
|
||||
|
||||
base = (meta.get("base_url") or "").rstrip("/")
|
||||
try:
|
||||
with httpx.Client(timeout=150.0) as client:
|
||||
def _post(mname: str):
|
||||
return client.post(
|
||||
f"{base}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json={
|
||||
"model": mname,
|
||||
"temperature": 0.0,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
},
|
||||
)
|
||||
|
||||
resp = _post(model_name)
|
||||
if resp.status_code >= 400:
|
||||
alt = "qwen-vl-plus"
|
||||
err = resp.text[:300]
|
||||
if model_name != alt:
|
||||
notes.append(f"{model_name} 打分 {resp.status_code},尝试 {alt}: {err}")
|
||||
resp = _post(alt)
|
||||
model_name = alt
|
||||
resp.raise_for_status()
|
||||
text = resp.json()["choices"][0]["message"]["content"]
|
||||
if isinstance(text, list):
|
||||
text = "".join((x.get("text") if isinstance(x, dict) else str(x)) for x in text)
|
||||
obj = _parse_json_obj(str(text))
|
||||
if not obj:
|
||||
notes.append("视觉打分返回非 JSON,本轮记 0 分")
|
||||
return {
|
||||
"score": 0,
|
||||
"pass": False,
|
||||
"fails": ["打分解析失败"],
|
||||
"passes": [],
|
||||
"raw": str(text)[:800],
|
||||
}, notes
|
||||
raw_score = int(obj.get("score") or 0)
|
||||
fails = [str(x) for x in (obj.get("fails") or []) if x]
|
||||
passes = [str(x) for x in (obj.get("passes") or []) if x]
|
||||
checks = obj.get("visual_checks") if isinstance(obj.get("visual_checks"), dict) else {}
|
||||
zones = obj.get("zone_checklist") if isinstance(obj.get("zone_checklist"), dict) else {}
|
||||
|
||||
score, fails, ok = _enforce_score_caps(
|
||||
raw_score,
|
||||
fails,
|
||||
checks,
|
||||
zones,
|
||||
has_real=has_real,
|
||||
draft=draft,
|
||||
prev_fails=prev_fails,
|
||||
)
|
||||
# 模型自称 pass 但服务端否决时记一条
|
||||
if bool(obj.get("pass")) and not ok:
|
||||
notes.append(
|
||||
f"视觉模型自评达标被否决:原始 {raw_score} → 校正 {score}(fails={len(fails)})"
|
||||
)
|
||||
notes.append(
|
||||
f"视觉打分 {meta['label']}/{model_name}: {score}/100"
|
||||
f"(原始 {raw_score},真页面={'是' if has_real else '否'})"
|
||||
)
|
||||
return {
|
||||
"score": score,
|
||||
"pass": ok,
|
||||
"fails": fails,
|
||||
"passes": passes,
|
||||
"visual_checks": checks,
|
||||
"zone_checklist": zones,
|
||||
"raw_score": raw_score,
|
||||
"patch_hints": obj.get("patch_hints") if isinstance(obj.get("patch_hints"), dict) else {},
|
||||
}, notes
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"score": 0, "pass": False, "fails": [f"打分调用失败: {e}"], "passes": []}, [str(e)]
|
||||
|
||||
|
||||
def _deep_merge_ui(dst: dict[str, Any], src: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(dst)
|
||||
for k, v in src.items():
|
||||
if v is None or v == "":
|
||||
continue
|
||||
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
||||
out[k] = {**out[k], **v}
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def apply_patch_hints(draft: dict[str, Any], hints: dict[str, Any]) -> dict[str, Any]:
|
||||
"""把视觉 patch_hints 机械合并进 draft(再交给代码模型细修)。"""
|
||||
if not hints:
|
||||
return draft
|
||||
meta = draft.setdefault("meta", {})
|
||||
for key in ("name", "platform_title", "project_context", "ui_preset"):
|
||||
if hints.get(f"meta.{key}"):
|
||||
meta[key] = hints[f"meta.{key}"]
|
||||
elif hints.get(key) and key in {"name", "platform_title", "project_context"}:
|
||||
meta[key] = hints[key]
|
||||
ui_hint = hints.get("meta.ui") or hints.get("ui")
|
||||
if isinstance(ui_hint, dict):
|
||||
meta["ui"] = _deep_merge_ui(meta.get("ui") or {}, ui_hint)
|
||||
return draft
|
||||
|
||||
|
||||
def refine_blueprint_against_fails(
|
||||
draft: dict[str, Any],
|
||||
fails: list[str],
|
||||
vision_extract: str,
|
||||
plan: str,
|
||||
provider_id: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""代码模型按差异清单改蓝图。"""
|
||||
instruction = (
|
||||
"根据视觉验收 fails 修改 draft,目标还原参考截图。"
|
||||
"只改 meta / meta.ui / pages 标题与 dashboard widgets 配置;"
|
||||
"禁止改成无关行业模板;禁止删除 entities。"
|
||||
"必须把 fails 中的原文写进对应字段。"
|
||||
"返回完整 draft JSON。"
|
||||
)
|
||||
prompt = (
|
||||
f"{instruction}\n\n[视觉摘录]\n{vision_extract}\n\n[界面计划]\n{plan}\n\n"
|
||||
f"[fails]\n" + "\n".join(f"- {x}" for x in fails)
|
||||
)
|
||||
return enhance_blueprint_with_llm(draft, prompt, "fidelity_refine", provider_id, model)
|
||||
|
||||
|
||||
def run_fidelity_loop(
|
||||
draft: dict[str, Any],
|
||||
ref_images: list[tuple[str, bytes, str]],
|
||||
vision_extract: str,
|
||||
*,
|
||||
llm_provider: str | None = None,
|
||||
llm_model: str | None = None,
|
||||
vision_model: str | None = None,
|
||||
harden_fn=None,
|
||||
baseline: dict[str, Any] | None = None,
|
||||
preview_rows: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], list[str]]:
|
||||
"""
|
||||
返回 (draft, report, notes)。
|
||||
report: {target, rounds:[{score,fails,passes}], final_score, passed}
|
||||
"""
|
||||
notes: list[str] = []
|
||||
log = glog()
|
||||
if not fidelity_enabled():
|
||||
if log:
|
||||
log.stage("fidelity_loop", "已关闭 FIDELITY_LOOP=0")
|
||||
return draft, {"skipped": True, "reason": "FIDELITY_LOOP=0"}, ["还原度迭代已关闭"]
|
||||
if not ref_images:
|
||||
if log:
|
||||
log.stage("fidelity_loop", "无参考截图")
|
||||
return draft, {"skipped": True, "reason": "no_images"}, ["无参考截图,跳过还原度迭代"]
|
||||
|
||||
target = fidelity_target()
|
||||
max_rounds = fidelity_max_rounds()
|
||||
rounds: list[dict[str, Any]] = []
|
||||
current = draft
|
||||
notes.append(f"开始还原度迭代:目标>={target}%,最多 {max_rounds} 轮")
|
||||
if log:
|
||||
log.notes(notes[-1:])
|
||||
log.info(
|
||||
"fidelity.begin",
|
||||
target=target,
|
||||
max_rounds=max_rounds,
|
||||
ref_images=len(ref_images),
|
||||
preview_rows=len(preview_rows or []),
|
||||
)
|
||||
|
||||
for i in range(1, max_rounds + 1):
|
||||
plan = ui_plan_from_draft(current)
|
||||
preview, preview_note, is_real = _try_preview_screenshot(
|
||||
current, preview_rows=preview_rows
|
||||
)
|
||||
notes.append(f"第{i}轮: {preview_note}")
|
||||
if log:
|
||||
log.stage(
|
||||
f"fidelity_round_{i}",
|
||||
preview_note,
|
||||
round=i,
|
||||
real_screen=is_real,
|
||||
)
|
||||
prev_fails = list(rounds[-1].get("fails") or []) if rounds else []
|
||||
score_obj, score_notes = vision_score_fidelity(
|
||||
ref_images,
|
||||
plan,
|
||||
vision_extract,
|
||||
preview,
|
||||
llm_provider,
|
||||
vision_model, # 只用视觉模型,不用 deepseek-chat 等文本模型名
|
||||
is_real_screen=is_real,
|
||||
draft=current,
|
||||
prev_fails=prev_fails,
|
||||
)
|
||||
notes.extend(score_notes)
|
||||
if log:
|
||||
log.notes(score_notes)
|
||||
log.info(
|
||||
f"fidelity.round_{i}.score",
|
||||
round=i,
|
||||
score=score_obj.get("score"),
|
||||
raw_score=score_obj.get("raw_score"),
|
||||
passed=bool(score_obj.get("pass")),
|
||||
fails_n=len(score_obj.get("fails") or []),
|
||||
)
|
||||
rounds.append(
|
||||
{
|
||||
"round": i,
|
||||
"score": score_obj.get("score"),
|
||||
"raw_score": score_obj.get("raw_score"),
|
||||
"pass": score_obj.get("pass"),
|
||||
"fails": score_obj.get("fails") or [],
|
||||
"passes": score_obj.get("passes") or [],
|
||||
"zone_checklist": score_obj.get("zone_checklist") or {},
|
||||
"real_screen": is_real,
|
||||
}
|
||||
)
|
||||
if score_obj.get("pass"):
|
||||
notes.append(f"第{i}轮达标:{score_obj.get('score')}% ≥ {target}%(已对照真页面)")
|
||||
if log:
|
||||
log.note(notes[-1])
|
||||
break
|
||||
|
||||
fails = list(score_obj.get("fails") or [])
|
||||
if not fails:
|
||||
fails = [f"总分仅 {score_obj.get('score')},未达 {target},请按视觉摘录补全 meta.ui"]
|
||||
if log:
|
||||
log.info(
|
||||
f"fidelity.round_{i}.patch",
|
||||
round=i,
|
||||
fails="; ".join(fails[:8]),
|
||||
)
|
||||
current = apply_patch_hints(current, score_obj.get("patch_hints") or {})
|
||||
current, refine_notes = refine_blueprint_against_fails(
|
||||
current, fails, vision_extract, plan, llm_provider, llm_model
|
||||
)
|
||||
notes.extend(refine_notes)
|
||||
if log:
|
||||
log.notes(refine_notes)
|
||||
if harden_fn and baseline is not None:
|
||||
current = harden_fn(current, baseline)
|
||||
|
||||
final = rounds[-1]["score"] if rounds else 0
|
||||
report = {
|
||||
"target": target,
|
||||
"max_rounds": max_rounds,
|
||||
"rounds": rounds,
|
||||
"final_score": final,
|
||||
"passed": bool(rounds and rounds[-1].get("pass")),
|
||||
}
|
||||
if not report["passed"]:
|
||||
notes.append(
|
||||
f"还原度迭代结束未达标:最终 {final}%(目标 {target}%),请根据 fails 继续生成或手工改蓝图"
|
||||
)
|
||||
if log:
|
||||
log.warning(notes[-1], final_score=final, target=target)
|
||||
elif log:
|
||||
log.info("fidelity.passed", final_score=final, target=target)
|
||||
return current, report, notes
|
||||
173
ai-service/generate_log.py
Normal file
173
ai-service/generate_log.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""蓝图生成过程日志:控制台 + 落盘,便于对账。
|
||||
|
||||
环境变量:
|
||||
- GENERATE_LOG_DIR:日志目录(默认仓库 .runtime/logs/generate)
|
||||
- GENERATE_LOG_LEVEL:DEBUG/INFO/WARNING(默认 INFO)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_root = Path(__file__).resolve().parents[1]
|
||||
_LOGGER_NAME = "aijianzhan.generate"
|
||||
_setup_lock = threading.Lock()
|
||||
_configured = False
|
||||
|
||||
_current: ContextVar["GenerateTrace | None"] = ContextVar("generate_trace", default=None)
|
||||
|
||||
|
||||
def _log_dir() -> Path:
|
||||
raw = (os.getenv("GENERATE_LOG_DIR") or "").strip()
|
||||
if raw:
|
||||
return Path(raw).expanduser().resolve()
|
||||
return (_root / ".runtime" / "logs" / "generate").resolve()
|
||||
|
||||
|
||||
def _level() -> int:
|
||||
name = (os.getenv("GENERATE_LOG_LEVEL") or "INFO").strip().upper()
|
||||
return getattr(logging, name, logging.INFO)
|
||||
|
||||
|
||||
def ensure_logging() -> logging.Logger:
|
||||
"""幂等配置:stdout + 按日滚动的 generate.log。"""
|
||||
global _configured
|
||||
logger = logging.getLogger(_LOGGER_NAME)
|
||||
if _configured:
|
||||
return logger
|
||||
with _setup_lock:
|
||||
if _configured:
|
||||
return logger
|
||||
logger.setLevel(_level())
|
||||
logger.propagate = False
|
||||
fmt = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
sh = logging.StreamHandler()
|
||||
sh.setLevel(_level())
|
||||
sh.setFormatter(fmt)
|
||||
logger.addHandler(sh)
|
||||
try:
|
||||
d = _log_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
fh = logging.FileHandler(d / "generate.log", encoding="utf-8")
|
||||
fh.setLevel(_level())
|
||||
fh.setFormatter(fmt)
|
||||
logger.addHandler(fh)
|
||||
except OSError as e:
|
||||
logger.warning("无法写入生成日志目录: %s", e)
|
||||
_configured = True
|
||||
return logger
|
||||
|
||||
|
||||
class GenerateTrace:
|
||||
"""单次 /apps/generate 请求的对账轨迹。"""
|
||||
|
||||
def __init__(self, run_id: str | None = None) -> None:
|
||||
self.run_id = run_id or uuid.uuid4().hex[:10]
|
||||
self.started_at = datetime.now(timezone.utc).isoformat()
|
||||
self.lines: list[dict[str, Any]] = []
|
||||
self._token = None
|
||||
self._logger = ensure_logging()
|
||||
self._run_file: Path | None = None
|
||||
try:
|
||||
d = _log_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
self._run_file = d / f"run_{self.run_id}.log"
|
||||
except OSError:
|
||||
self._run_file = None
|
||||
|
||||
def __enter__(self) -> "GenerateTrace":
|
||||
self._token = _current.set(self)
|
||||
self.info("generate.start", run_id=self.run_id)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
if exc_type is not None:
|
||||
self.error(f"generate.failed: {exc}", error=str(exc))
|
||||
else:
|
||||
self.info("generate.done")
|
||||
if self._token is not None:
|
||||
_current.reset(self._token)
|
||||
return False
|
||||
|
||||
def _emit(self, level: str, message: str, **fields: Any) -> None:
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
entry = {"ts": ts, "level": level, "message": message}
|
||||
if fields:
|
||||
# 仅保留可 JSON 序列化的简单字段
|
||||
clean: dict[str, Any] = {}
|
||||
for k, v in fields.items():
|
||||
if v is None:
|
||||
continue
|
||||
if isinstance(v, (str, int, float, bool)):
|
||||
clean[k] = v
|
||||
elif isinstance(v, (list, tuple)) and all(
|
||||
isinstance(x, (str, int, float, bool)) for x in v
|
||||
):
|
||||
clean[k] = list(v)
|
||||
else:
|
||||
clean[k] = str(v)[:500]
|
||||
entry.update(clean)
|
||||
self.lines.append(entry)
|
||||
|
||||
extra = " ".join(f"{k}={v}" for k, v in entry.items() if k not in {"ts", "level", "message"})
|
||||
line = f"[{self.run_id}] {message}" + (f" | {extra}" if extra else "")
|
||||
log_fn = getattr(self._logger, level if level in {"debug", "info", "warning", "error"} else "info")
|
||||
log_fn(line)
|
||||
if self._run_file is not None:
|
||||
try:
|
||||
with self._run_file.open("a", encoding="utf-8") as f:
|
||||
f.write(f"{ts} [{level.upper()}] {line}\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def debug(self, message: str, **fields: Any) -> None:
|
||||
self._emit("debug", message, **fields)
|
||||
|
||||
def info(self, message: str, **fields: Any) -> None:
|
||||
self._emit("info", message, **fields)
|
||||
|
||||
def warning(self, message: str, **fields: Any) -> None:
|
||||
self._emit("warning", message, **fields)
|
||||
|
||||
def error(self, message: str, **fields: Any) -> None:
|
||||
self._emit("error", message, **fields)
|
||||
|
||||
def stage(self, name: str, detail: str = "", **fields: Any) -> None:
|
||||
msg = f"stage.{name}" + (f": {detail}" if detail else "")
|
||||
self.info(msg, stage=name, **fields)
|
||||
|
||||
def note(self, text: str) -> None:
|
||||
"""把 warnings/notes 同步进日志。"""
|
||||
t = (text or "").strip()
|
||||
if t:
|
||||
self.info(t)
|
||||
|
||||
def notes(self, items: list[str] | None) -> None:
|
||||
for n in items or []:
|
||||
self.note(str(n))
|
||||
|
||||
def as_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"run_id": self.run_id,
|
||||
"started_at": self.started_at,
|
||||
"log_file": str(self._run_file) if self._run_file else "",
|
||||
"lines": list(self.lines),
|
||||
}
|
||||
|
||||
|
||||
def get_trace() -> GenerateTrace | None:
|
||||
return _current.get()
|
||||
|
||||
|
||||
def glog() -> GenerateTrace | None:
|
||||
"""当前请求轨迹;无上下文时返回 None(调用方需判空)。"""
|
||||
return get_trace()
|
||||
219
ai-service/generation_rules.py
Normal file
219
ai-service/generation_rules.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""生成时由服务端注入的规则,对用户不可见。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# 用户明确不要按截图还原时才退出忠实模式
|
||||
_SCREENSHOT_OPT_OUT = (
|
||||
"不要还原截图",
|
||||
"不按截图",
|
||||
"无需还原截图",
|
||||
"忽略截图布局",
|
||||
"自定义布局",
|
||||
"改成通用后台",
|
||||
"不要一模一样",
|
||||
"重新设计界面",
|
||||
"不要照搬截图",
|
||||
)
|
||||
|
||||
_SCREENSHOT_OPT_IN = (
|
||||
"还原截图",
|
||||
"对齐截图",
|
||||
"一模一样",
|
||||
"截图还原",
|
||||
"screenshot_faithful",
|
||||
)
|
||||
|
||||
DEFAULT_ACTION_LABELS: dict[str, str] = {
|
||||
"create": "新增",
|
||||
"refresh": "刷新",
|
||||
"import": "导入 Excel",
|
||||
"export": "导出 Excel",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"back": "返回",
|
||||
"search": "查询",
|
||||
}
|
||||
|
||||
# 较长别名优先匹配
|
||||
_ACTION_ALIASES: list[tuple[str, str]] = [
|
||||
("导入 excel", "import"),
|
||||
("导出 excel", "export"),
|
||||
("导入xlsx", "import"),
|
||||
("导出xlsx", "export"),
|
||||
("导入csv", "import"),
|
||||
("导出csv", "export"),
|
||||
("导入", "import"),
|
||||
("导出", "export"),
|
||||
("新建", "create"),
|
||||
("新增", "create"),
|
||||
("添加", "create"),
|
||||
("创建", "create"),
|
||||
("刷新", "refresh"),
|
||||
("重载", "refresh"),
|
||||
("编辑", "edit"),
|
||||
("修改", "edit"),
|
||||
("删除", "delete"),
|
||||
("移除", "delete"),
|
||||
("保存", "save"),
|
||||
("取消", "cancel"),
|
||||
("返回", "back"),
|
||||
("查询", "search"),
|
||||
("搜索", "search"),
|
||||
("筛选", "search"),
|
||||
]
|
||||
|
||||
SYSTEM_RULES = """
|
||||
[系统规则 · 用户不可见]
|
||||
你是业务前端生成器。输出 App Blueprint(entities / apis / pages / widgets)。
|
||||
适用于任意行业,禁止把所有应用生成成同一种通用绿主题 CRUD 壳。
|
||||
|
||||
还原原则(全局):
|
||||
- 有截图/HTML:默认一模一样(meta.ui_preset=screenshot_faithful,dashboard layout.preset=screenshot_faithful)。
|
||||
用户文字点名要改的才改;未提及的顶栏/导航/筛选/图表/表格/按键/配色保持与截图一致。
|
||||
- 生成闭环(有截图时由服务端自动执行):视觉模型摘录 → 代码模型出蓝图 → **发布并截取 Web 真页面**
|
||||
→ 视觉模型对照原图与真页面打分 → 按差异再改蓝图,直到还原度≥95%
|
||||
(环境变量:FIDELITY_TARGET / FIDELITY_MAX_ROUNDS / FIDELITY_REAL_SCREEN)。
|
||||
- 无截图:严格按用户文字与数据字段还原;勿套默认「记录列表/概览」。
|
||||
- 业务名称、平台抬头、页签、按钮文案必须来自用户/截图/HTML,禁止擅自换成套话。
|
||||
- 尽量写全 meta.platform_title / project_context / ui.nav_items / ui.filter_radios /
|
||||
ui.select_field / ui.filter_hint / ui.*_side_label / ui.table_title(无材料则留空,禁止编造行业词)。
|
||||
|
||||
其它:
|
||||
- 功能按键写入 pages[].layout.actions + action_labels,原文显示。
|
||||
- 数据:Excel/CSV 表头→字段;JSON list→表;并行数组先展平再关联。snake_case;高基数不做 enum。
|
||||
- REST:GET/POST/PUT/DELETE;看板 widgets(kpi/line_chart/status_strip/table/bar/pie)按截图或需求取舍。
|
||||
- 全量导入:校验失败只记错误,不提前中断。
|
||||
""".strip()
|
||||
|
||||
SCREENSHOT_RULES = """
|
||||
[截图忠实 · 用户不可见 · 适用于任意业务]
|
||||
已上传界面截图。目标:展示页与截图一模一样(screenshot_faithful)。
|
||||
必须从截图提取并写入蓝图:平台抬头、系统名称、页签文案、筛选控件(单选/下拉)、工具栏按键原文、
|
||||
主区分区(含左侧竖标签文案)、图表类型与系列名、表格列名。
|
||||
主图为「着色测点/短棒 + 设计曲线」时:meta.ui.chart_style=section_marks,并写
|
||||
value_field / color_field / design_field / legend_items / y_axis_label / invert_y。
|
||||
监督条为上灰下绿双段时:status_strip.variant=stacked_days,value_field+secondary_field,cycle_days。
|
||||
禁止只有侧标「主图」却不配 line_chart widget 或让主图区空白。
|
||||
多张图若为同一页展开/收起/滚动态:合并为一套布局,以信息最完整的一帧为准,不要生成两套互斥页面。
|
||||
禁止套用其它行业模板(含铁路沉降专用文案),除非截图本身就是该业务。
|
||||
""".strip()
|
||||
|
||||
|
||||
def screenshot_opted_out(prompt: str) -> bool:
|
||||
p = prompt or ""
|
||||
return any(k in p for k in _SCREENSHOT_OPT_OUT)
|
||||
|
||||
|
||||
def wants_screenshot_layout(prompt: str, image_count: int, html_count: int = 0) -> bool:
|
||||
"""有截图或页面 HTML 即默认忠实还原;用户显式退出除外。"""
|
||||
p = prompt or ""
|
||||
if screenshot_opted_out(p):
|
||||
return False
|
||||
if image_count > 0 or html_count > 0:
|
||||
return True
|
||||
if any(k in p for k in _SCREENSHOT_OPT_IN):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _map_button_label(raw: str) -> tuple[str, str] | None:
|
||||
"""返回 (action_key, display_label)。"""
|
||||
label = re.sub(r"\s+", " ", (raw or "").strip()).strip("。.;;")
|
||||
if not label or len(label) > 32:
|
||||
return None
|
||||
low = label.lower().replace(" ", "")
|
||||
for alias, key in _ACTION_ALIASES:
|
||||
a = alias.replace(" ", "")
|
||||
if low == a or low.startswith(a) or a in low:
|
||||
return key, label
|
||||
slug = re.sub(r"[^a-z0-9_]+", "_", low)[:24].strip("_") or "action"
|
||||
return f"custom_{slug}", label
|
||||
|
||||
|
||||
def parse_action_bar(prompt: str) -> dict[str, Any]:
|
||||
"""从用户说明/截图理解中解析功能按键,得到 actions 顺序与 action_labels。"""
|
||||
p = prompt or ""
|
||||
labels: dict[str, str] = {}
|
||||
order: list[str] = []
|
||||
|
||||
def add(raw: str) -> None:
|
||||
mapped = _map_button_label(raw)
|
||||
if not mapped:
|
||||
return
|
||||
key, lab = mapped
|
||||
if key not in labels:
|
||||
order.append(key)
|
||||
labels[key] = lab
|
||||
|
||||
for m in re.finditer(
|
||||
r"(?:操作|功能按键|按键|按钮|工具栏|工具条)[::]\s*(.+)",
|
||||
p,
|
||||
):
|
||||
chunk = m.group(1).splitlines()[0]
|
||||
chunk = re.split(r"[。;;((]", chunk, 1)[0]
|
||||
for part in re.split(r"[、,,/||]\s*", chunk):
|
||||
add(part)
|
||||
|
||||
for m in re.finditer(
|
||||
r"(?:包含|含有|提供|支持)[::]?\s*((?:新增|新建|刷新|导入|导出|编辑|删除|查询|保存)[^。\n]{0,40})",
|
||||
p,
|
||||
):
|
||||
for part in re.split(r"[、,,/||]\s*", m.group(1)):
|
||||
add(part)
|
||||
|
||||
for m in re.finditer(r"(?:工具栏|顶栏)?按钮[::\s]+(.+)", p):
|
||||
chunk = m.group(1).splitlines()[0][:80]
|
||||
for part in re.split(r"[、,,/||\s]{1,}", chunk):
|
||||
if part.strip():
|
||||
add(part)
|
||||
|
||||
if not order:
|
||||
return {}
|
||||
|
||||
for k in ("edit", "delete"):
|
||||
if k not in labels:
|
||||
labels[k] = DEFAULT_ACTION_LABELS[k]
|
||||
|
||||
toolbar = [k for k in order if k not in ("edit", "delete")]
|
||||
actions = toolbar + [k for k in ("edit", "delete") if k in labels]
|
||||
seen: set[str] = set()
|
||||
uniq: list[str] = []
|
||||
for k in actions:
|
||||
if k not in seen:
|
||||
seen.add(k)
|
||||
uniq.append(k)
|
||||
|
||||
return {"actions": uniq, "action_labels": labels, "toolbar_from_user": True}
|
||||
|
||||
|
||||
def compose_effective_prompt(
|
||||
user_prompt: str,
|
||||
image_count: int,
|
||||
vision_text: str = "",
|
||||
html_layout_text: str = "",
|
||||
html_count: int = 0,
|
||||
) -> str:
|
||||
"""把系统规则拼进有效提示词;控制台只传用户原文。"""
|
||||
parts = [SYSTEM_RULES]
|
||||
if wants_screenshot_layout(user_prompt, image_count, html_count):
|
||||
parts.append(SCREENSHOT_RULES)
|
||||
if html_count:
|
||||
parts.append(
|
||||
"[HTML 布局优先 · 用户不可见]\n"
|
||||
"已上传浏览器另存为的 HTML/MHTML。文案与控件名以 HTML 为准;分区与配色以截图为准。"
|
||||
)
|
||||
user = (user_prompt or "").strip()
|
||||
if user:
|
||||
parts.append("[用户需求]\n" + user)
|
||||
else:
|
||||
parts.append("[用户需求]\n(未填写文字需求:仅依据数据文件、截图与 HTML 生成。)")
|
||||
if html_layout_text:
|
||||
parts.append(html_layout_text)
|
||||
if vision_text:
|
||||
parts.append("[截图理解]\n" + vision_text)
|
||||
return "\n\n".join(parts)
|
||||
370
ai-service/html_layout.py
Normal file
370
ai-service/html_layout.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""从 Ctrl+S 保存的 HTML / MHTML 提取布局真源,配合截图做更精确还原(行业无关)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import email
|
||||
import re
|
||||
from email import policy
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any
|
||||
|
||||
|
||||
_SKIP_TAGS = {"script", "style", "noscript", "svg", "path"}
|
||||
_MAX_TEXT = 48
|
||||
_NAV_NOISE = {
|
||||
"欢迎您",
|
||||
"退出",
|
||||
"帮助",
|
||||
"登录",
|
||||
"注册",
|
||||
"返回",
|
||||
"首页",
|
||||
"客服",
|
||||
"电话",
|
||||
"反馈",
|
||||
"消息",
|
||||
"系统消息",
|
||||
}
|
||||
# 泛化业务词:用于区分「姓名」与「页签」
|
||||
_BUSINESS_HINTS = (
|
||||
"列表",
|
||||
"新增",
|
||||
"创建",
|
||||
"看板",
|
||||
"概览",
|
||||
"统计",
|
||||
"报表",
|
||||
"管理",
|
||||
"设置",
|
||||
"详情",
|
||||
"汇总",
|
||||
"筛选",
|
||||
"查询",
|
||||
"导入",
|
||||
"导出",
|
||||
"设备",
|
||||
"人员",
|
||||
"信息",
|
||||
"数据",
|
||||
"记录",
|
||||
"测点",
|
||||
"断面",
|
||||
"工点",
|
||||
"观测",
|
||||
"订单",
|
||||
"商品",
|
||||
"库存",
|
||||
"客户",
|
||||
)
|
||||
|
||||
|
||||
def _is_nav_noise(text: str) -> bool:
|
||||
t = (text or "").strip()
|
||||
if not t or t in _NAV_NOISE:
|
||||
return True
|
||||
if len(t) <= 3 and re.fullmatch(r"[\u4e00-\u9fff]{2,3}", t):
|
||||
if not any(b in t for b in _BUSINESS_HINTS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class _LayoutHTMLParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.title = ""
|
||||
self._in_title = False
|
||||
self._skip = 0
|
||||
self.headings: list[str] = []
|
||||
self.nav_items: list[str] = []
|
||||
self.buttons: list[str] = []
|
||||
self.labels: list[str] = []
|
||||
self.table_headers: list[list[str]] = []
|
||||
self.radio_options: list[str] = []
|
||||
self._cur_th: list[str] = []
|
||||
self._in_th = False
|
||||
self._in_nav = 0
|
||||
self._in_button = False
|
||||
self._in_label = False
|
||||
self._pending_radio = False
|
||||
self._buf = ""
|
||||
self.meta_desc = ""
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
t = tag.lower()
|
||||
ad = {k.lower(): (v or "") for k, v in attrs}
|
||||
if t in _SKIP_TAGS:
|
||||
self._skip += 1
|
||||
return
|
||||
if self._skip:
|
||||
return
|
||||
if t == "title":
|
||||
self._in_title = True
|
||||
self._buf = ""
|
||||
elif t == "meta" and ad.get("name", "").lower() == "description":
|
||||
self.meta_desc = (ad.get("content") or "")[:200]
|
||||
elif t in {"nav", "header"} or "nav" in (ad.get("class") or "").lower() or ad.get("role") == "navigation":
|
||||
self._in_nav += 1
|
||||
elif t in {"button"} or ad.get("role") == "button" or (
|
||||
t == "input" and ad.get("type") in {"button", "submit", "reset"}
|
||||
):
|
||||
self._in_button = True
|
||||
self._buf = ad.get("value") or ad.get("aria-label") or ad.get("title") or ""
|
||||
elif t == "input" and ad.get("type") == "radio":
|
||||
# 值若是纯数字/无意义,等后续文本当选项;有中文 value 则直接采用
|
||||
self._pending_radio = True
|
||||
val = (ad.get("value") or "").strip()
|
||||
if val and val not in {"", "on"} and not re.fullmatch(r"\d+", val) and len(val) <= 16:
|
||||
self.radio_options.append(val)
|
||||
elif t == "a" and self._in_nav:
|
||||
self._buf = ""
|
||||
elif t in {"h1", "h2", "h3", "h4"}:
|
||||
self._buf = ""
|
||||
elif t == "label":
|
||||
self._in_label = True
|
||||
self._buf = ""
|
||||
elif t == "th":
|
||||
self._in_th = True
|
||||
self._buf = ""
|
||||
elif t == "tr":
|
||||
self._cur_th = []
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
t = tag.lower()
|
||||
if t in _SKIP_TAGS:
|
||||
if self._skip:
|
||||
self._skip -= 1
|
||||
return
|
||||
if self._skip:
|
||||
return
|
||||
text = re.sub(r"\s+", " ", self._buf or "").strip()
|
||||
if t == "title":
|
||||
self._in_title = False
|
||||
if text:
|
||||
self.title = text[:80]
|
||||
elif t in {"nav", "header"}:
|
||||
if self._in_nav:
|
||||
self._in_nav -= 1
|
||||
elif t in {"button"} or self._in_button and t in {"input", "a", "span", "div"}:
|
||||
if text and len(text) <= _MAX_TEXT:
|
||||
self.buttons.append(text)
|
||||
self._in_button = False
|
||||
elif t == "a" and self._in_nav and text and len(text) <= _MAX_TEXT:
|
||||
if not _is_nav_noise(text):
|
||||
self.nav_items.append(text)
|
||||
elif t in {"h1", "h2", "h3", "h4"} and text and len(text) <= 64:
|
||||
if not _is_nav_noise(text):
|
||||
self.headings.append(text)
|
||||
elif t == "label":
|
||||
self._in_label = False
|
||||
cleaned = text.rstrip("::")
|
||||
if cleaned and len(cleaned) <= _MAX_TEXT:
|
||||
# 单选旁的短文案不要当导航噪声丢掉(如「全部」「类型A」)
|
||||
if self._pending_radio and 1 <= len(cleaned) <= 12:
|
||||
self.radio_options.append(cleaned)
|
||||
elif not _is_nav_noise(cleaned):
|
||||
self.labels.append(cleaned)
|
||||
self._pending_radio = False
|
||||
elif t == "th":
|
||||
self._in_th = False
|
||||
if text:
|
||||
self._cur_th.append(text[:32])
|
||||
elif t == "tr" and self._cur_th:
|
||||
self.table_headers.append(self._cur_th[:12])
|
||||
self._cur_th = []
|
||||
self._buf = ""
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._skip:
|
||||
return
|
||||
if self._in_title or self._in_button or self._in_nav or self._in_th or self._in_label:
|
||||
self._buf += data
|
||||
return
|
||||
self._buf += data
|
||||
|
||||
|
||||
def _decode_bytes(raw: bytes) -> str:
|
||||
for enc in ("utf-8", "gb18030", "gbk", "latin-1"):
|
||||
try:
|
||||
return raw.decode(enc)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="ignore")
|
||||
|
||||
|
||||
def extract_html_from_mhtml(raw: bytes) -> str:
|
||||
msg = email.message_from_bytes(raw, policy=policy.default)
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
ctype = (part.get_content_type() or "").lower()
|
||||
if ctype in {"text/html", "application/xhtml+xml"}:
|
||||
try:
|
||||
return part.get_content()
|
||||
except Exception: # noqa: BLE001
|
||||
payload = part.get_payload(decode=True) or b""
|
||||
return _decode_bytes(payload)
|
||||
text = _decode_bytes(raw)
|
||||
i = text.lower().find("<html")
|
||||
if i >= 0:
|
||||
return text[i:]
|
||||
return text
|
||||
|
||||
|
||||
def parse_layout_file(filename: str, content: bytes) -> dict[str, Any]:
|
||||
name = (filename or "").lower()
|
||||
if name.endswith((".mhtml", ".mht")):
|
||||
html = extract_html_from_mhtml(content)
|
||||
source = "mhtml"
|
||||
elif name.endswith((".html", ".htm", ".xhtml")):
|
||||
html = _decode_bytes(content)
|
||||
source = "html"
|
||||
else:
|
||||
html = _decode_bytes(content)
|
||||
source = "text"
|
||||
|
||||
if len(html) > 2_000_000:
|
||||
html = html[:2_000_000]
|
||||
|
||||
parser = _LayoutHTMLParser()
|
||||
try:
|
||||
parser.feed(html)
|
||||
parser.close()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "error": str(e), "filename": filename, "source": source}
|
||||
|
||||
# 兜底:radio 后紧跟的短文案(常见 value=数字 + 中文标签)
|
||||
for m in re.finditer(
|
||||
r'type=["\']radio["\'][^>]*>\s*(?:<[^>]+>\s*)*([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9]{0,11})',
|
||||
html,
|
||||
flags=re.I,
|
||||
):
|
||||
lab = m.group(1).strip()
|
||||
if lab and not re.fullmatch(r"\d+", lab):
|
||||
parser.radio_options.append(lab)
|
||||
|
||||
def uniq(items: list[str], limit: int = 40) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for x in items:
|
||||
x = x.strip()
|
||||
if not x or x in seen:
|
||||
continue
|
||||
seen.add(x)
|
||||
out.append(x)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
ths = parser.table_headers[0] if parser.table_headers else []
|
||||
return {
|
||||
"ok": True,
|
||||
"filename": filename,
|
||||
"source": source,
|
||||
"title": parser.title,
|
||||
"meta_description": parser.meta_desc,
|
||||
"headings": uniq(parser.headings, 20),
|
||||
"nav_items": uniq(parser.nav_items, 24),
|
||||
"buttons": uniq(parser.buttons, 30),
|
||||
"labels": uniq(parser.labels, 30),
|
||||
"radio_options": uniq(parser.radio_options, 12),
|
||||
"table_headers": uniq(ths, 16),
|
||||
"table_header_rows": parser.table_headers[:3],
|
||||
}
|
||||
|
||||
|
||||
def layout_summary_text(parsed: dict[str, Any]) -> str:
|
||||
if not parsed or not parsed.get("ok"):
|
||||
err = (parsed or {}).get("error") or "parse failed"
|
||||
return f"[页面 HTML 解析失败] {err}"
|
||||
lines = [
|
||||
"[页面 HTML 布局真源 · 优先于猜测 · 禁止套用其它行业模板]",
|
||||
f"文件: {parsed.get('filename')} ({parsed.get('source')})",
|
||||
]
|
||||
if parsed.get("title"):
|
||||
lines.append(f"文档标题: {parsed['title']}")
|
||||
if parsed.get("headings"):
|
||||
lines.append("标题层级: " + " / ".join(parsed["headings"][:12]))
|
||||
if parsed.get("nav_items"):
|
||||
lines.append("导航页签: " + "、".join(parsed["nav_items"][:16]))
|
||||
if parsed.get("buttons"):
|
||||
lines.append("操作按键: " + "、".join(parsed["buttons"][:20]))
|
||||
if parsed.get("labels"):
|
||||
lines.append("分区/筛选项标签: " + "、".join(parsed["labels"][:20]))
|
||||
if parsed.get("radio_options"):
|
||||
lines.append("单选选项: " + "、".join(parsed["radio_options"][:12]))
|
||||
if parsed.get("table_headers"):
|
||||
lines.append("表格列: " + "、".join(parsed["table_headers"][:16]))
|
||||
lines.append(
|
||||
"要求:pages 标题、导航、action_labels、表格列、筛选文案必须优先采用上述 HTML 原文;"
|
||||
"meta.ui_preset=screenshot_faithful;文案以 HTML 为准、分区以截图为准;"
|
||||
"禁止擅自换成其它业务的固定文案。"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def merge_layout_into_ui_hints(hints: dict[str, Any], parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
"""把 HTML 解析结果并入 ui_hints。只使用文件里的原文,不写死行业词。"""
|
||||
if not parsed or not parsed.get("ok"):
|
||||
return hints
|
||||
ui = hints.setdefault("ui", {})
|
||||
|
||||
if not hints.get("app_name") and parsed.get("title"):
|
||||
title = str(parsed["title"])
|
||||
parts = re.split(r"\s*[-_||]\s*", title)
|
||||
if parts:
|
||||
cand = parts[0][:64]
|
||||
if cand and not _is_nav_noise(cand):
|
||||
hints["app_name"] = cand
|
||||
if len(parts) > 1 and "platform_title" not in ui:
|
||||
ui["platform_title"] = parts[-1][:64]
|
||||
|
||||
labels = [x for x in (parsed.get("labels") or []) if x and not _is_nav_noise(x)]
|
||||
# 按出现顺序把较长分区标签分给 图 / 条 / 表(通用,不认行业)
|
||||
region_labels = [x for x in labels if len(x) >= 4 and not x.startswith("(")]
|
||||
if region_labels:
|
||||
if len(region_labels) >= 1:
|
||||
ui.setdefault("chart_side_label", region_labels[0])
|
||||
hints.setdefault("chart_title", region_labels[0])
|
||||
if len(region_labels) >= 2:
|
||||
ui.setdefault("strip_side_label", region_labels[1])
|
||||
hints.setdefault("strip_title", region_labels[1])
|
||||
if len(region_labels) >= 3:
|
||||
ui.setdefault("table_side_label", region_labels[2])
|
||||
# 表格横标题:优先含「表」或最长的后续标签
|
||||
for lab in reversed(region_labels):
|
||||
if "表" in lab or len(lab) >= 8:
|
||||
hints.setdefault("table_title", lab)
|
||||
ui.setdefault("table_title", lab)
|
||||
break
|
||||
|
||||
nav = [x for x in (parsed.get("nav_items") or []) if not _is_nav_noise(x)]
|
||||
if nav:
|
||||
ui["nav_items"] = nav[:16]
|
||||
for n in nav:
|
||||
if any(k in n for k in ("看板", "概览", "统计", "dashboard")):
|
||||
hints.setdefault("dash_title", n)
|
||||
elif any(k in n for k in ("新增", "创建", "新建")):
|
||||
hints.setdefault("create_title", n)
|
||||
elif any(k in n for k in ("列表", "明细", "记录", "数据", "测点", "订单", "商品")):
|
||||
hints.setdefault("list_title", n)
|
||||
|
||||
if parsed.get("buttons"):
|
||||
hints["_html_buttons_line"] = "操作:" + "、".join(parsed["buttons"][:20])
|
||||
if parsed.get("table_headers"):
|
||||
ui["table_headers"] = parsed["table_headers"]
|
||||
if labels:
|
||||
ui.setdefault("filter_labels", labels[:12])
|
||||
|
||||
radios = [
|
||||
x
|
||||
for x in (parsed.get("radio_options") or [])
|
||||
if x
|
||||
and x not in {"全部", "all", "All"}
|
||||
and not re.fullmatch(r"\d+", x)
|
||||
and len(x) <= 12
|
||||
]
|
||||
if len(radios) >= 2:
|
||||
ui.setdefault("section_options", radios[:8])
|
||||
ui.setdefault("filter_radios", radios[:8])
|
||||
ui.setdefault("filter_style", "section_radios")
|
||||
|
||||
return hints
|
||||
418
ai-service/llm.py
Normal file
418
ai-service/llm.py
Normal file
@@ -0,0 +1,418 @@
|
||||
"""多厂商 LLM:供应商表从 etc/llm.yaml 加载(密钥仍走环境变量)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
_DEFAULT_CONFIG_CANDIDATES = (
|
||||
Path(__file__).resolve().parent / "etc" / "llm.yaml",
|
||||
Path("/app/etc/llm.yaml"),
|
||||
)
|
||||
|
||||
|
||||
def _config_path() -> Path:
|
||||
for key in ("AI_CONFIG_PATH", "LLM_CONFIG_PATH"):
|
||||
raw = (os.getenv(key) or "").strip()
|
||||
if raw:
|
||||
return Path(raw)
|
||||
for p in _DEFAULT_CONFIG_CANDIDATES:
|
||||
if p.is_file():
|
||||
return p
|
||||
return _DEFAULT_CONFIG_CANDIDATES[0]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_ai_config() -> dict[str, Any]:
|
||||
path = _config_path()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"AI 配置文件不存在: {path}(可用 AI_CONFIG_PATH / LLM_CONFIG_PATH 指定)"
|
||||
)
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"AI 配置格式错误(须为 mapping): {path}")
|
||||
providers = data.get("providers") or {}
|
||||
if not isinstance(providers, dict) or not providers:
|
||||
raise ValueError(f"AI 配置缺少 providers: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def reload_ai_config() -> dict[str, Any]:
|
||||
"""供热加载 / 测试清空缓存。"""
|
||||
load_ai_config.cache_clear()
|
||||
return load_ai_config()
|
||||
|
||||
|
||||
def get_providers() -> dict[str, dict[str, Any]]:
|
||||
raw = load_ai_config().get("providers") or {}
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for pid, meta in raw.items():
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
out[str(pid).strip().lower()] = dict(meta)
|
||||
return out
|
||||
|
||||
|
||||
# 兼容旧代码:PROVIDERS 为属性式访问,始终读当前配置
|
||||
class _ProvidersProxy(dict):
|
||||
def _sync(self) -> None:
|
||||
self.clear()
|
||||
self.update(get_providers())
|
||||
|
||||
def __contains__(self, key: object) -> bool: # type: ignore[override]
|
||||
self._sync()
|
||||
return dict.__contains__(self, key)
|
||||
|
||||
def __getitem__(self, key: str) -> dict[str, Any]:
|
||||
self._sync()
|
||||
return dict.__getitem__(self, key)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any: # type: ignore[override]
|
||||
self._sync()
|
||||
return dict.get(self, key, default)
|
||||
|
||||
def items(self): # type: ignore[override]
|
||||
self._sync()
|
||||
return dict.items(self)
|
||||
|
||||
def keys(self): # type: ignore[override]
|
||||
self._sync()
|
||||
return dict.keys(self)
|
||||
|
||||
def values(self): # type: ignore[override]
|
||||
self._sync()
|
||||
return dict.values(self)
|
||||
|
||||
|
||||
PROVIDERS: dict[str, dict[str, Any]] = _ProvidersProxy() # type: ignore[assignment]
|
||||
|
||||
|
||||
def _default_provider_id() -> str:
|
||||
cfg = load_ai_config()
|
||||
return (
|
||||
(os.getenv("LLM_PROVIDER") or "").strip().lower()
|
||||
or str(cfg.get("default_provider") or "deepseek").strip().lower()
|
||||
or "deepseek"
|
||||
)
|
||||
|
||||
|
||||
def _aliases() -> dict[str, str]:
|
||||
raw = load_ai_config().get("aliases") or {}
|
||||
return {str(k).strip().lower(): str(v).strip().lower() for k, v in raw.items()}
|
||||
|
||||
|
||||
def _vision_cfg() -> dict[str, Any]:
|
||||
v = load_ai_config().get("vision") or {}
|
||||
return v if isinstance(v, dict) else {}
|
||||
|
||||
|
||||
def _normalize_pid(provider_id: str | None, *, aliases: dict[str, str] | None = None) -> str:
|
||||
pid = (provider_id or "").strip().lower()
|
||||
amap = aliases if aliases is not None else _aliases()
|
||||
if pid in amap:
|
||||
pid = amap[pid]
|
||||
return pid
|
||||
|
||||
|
||||
def list_providers() -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for pid, meta in get_providers().items():
|
||||
key_env = meta.get("api_key_env") or ""
|
||||
configured = True
|
||||
if key_env:
|
||||
configured = bool(os.getenv(str(key_env), "").strip())
|
||||
out.append(
|
||||
{
|
||||
"id": pid,
|
||||
"label": meta.get("label") or pid,
|
||||
"configured": configured,
|
||||
"default_model": meta.get("default_model") or "",
|
||||
"models": meta.get("models") or [],
|
||||
"supports_vision": bool(meta.get("supports_vision")),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_provider(provider_id: str | None, model: str | None = None) -> tuple[str, dict[str, Any], str]:
|
||||
"""返回 (provider_id, meta, model_name)。"""
|
||||
providers = get_providers()
|
||||
pid = _normalize_pid(provider_id) or _default_provider_id()
|
||||
if pid not in providers:
|
||||
fallback = _default_provider_id()
|
||||
pid = fallback if fallback in providers else next(iter(providers))
|
||||
meta = dict(providers[pid])
|
||||
# 单供应商可被环境变量覆盖 base_url(如 DASHSCOPE_BASE_URL)
|
||||
env_base_key = f"{pid.upper()}_BASE_URL"
|
||||
env_base = (os.getenv(env_base_key) or "").strip()
|
||||
if env_base:
|
||||
meta["base_url"] = env_base
|
||||
elif pid == "dashscope":
|
||||
# 兼容旧变量名
|
||||
legacy = (os.getenv("DASHSCOPE_BASE_URL") or "").strip()
|
||||
if legacy:
|
||||
meta["base_url"] = legacy
|
||||
vision = _vision_cfg()
|
||||
if pid == (str(vision.get("provider") or "dashscope").strip().lower()):
|
||||
if (os.getenv("VISION_MODEL") or "").strip() and not (model or "").strip():
|
||||
meta["default_model"] = os.getenv("VISION_MODEL", "").strip()
|
||||
model_name = (model or "").strip() or str(meta.get("default_model") or "")
|
||||
return pid, meta, model_name
|
||||
|
||||
|
||||
def resolve_vision_provider(
|
||||
provider_id: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, dict[str, Any], str]:
|
||||
"""优先配置 vision / 环境变量 VISION_*;禁止把纯文本模型名传给视觉通道。"""
|
||||
providers = get_providers()
|
||||
vision = _vision_cfg()
|
||||
vision_aliases = {
|
||||
**_aliases(),
|
||||
**{
|
||||
str(k).strip().lower(): str(v).strip().lower()
|
||||
for k, v in (vision.get("aliases") or {}).items()
|
||||
},
|
||||
}
|
||||
env_pid = (os.getenv("VISION_PROVIDER") or "").strip().lower()
|
||||
cfg_pid = str(vision.get("provider") or "").strip().lower()
|
||||
vision_pid = _normalize_pid(env_pid or provider_id or cfg_pid, aliases=vision_aliases)
|
||||
|
||||
env_model = (os.getenv("VISION_MODEL") or "").strip()
|
||||
cfg_model = str(vision.get("model") or "").strip()
|
||||
passed = (model or "").strip()
|
||||
hints = [str(h).lower() for h in (load_ai_config().get("vision_model_hints") or [])]
|
||||
if not hints:
|
||||
hints = ["vl", "vision", "qwen3", "qwen-vl", "gpt-4o", "gemini"]
|
||||
vision_like = bool(
|
||||
passed
|
||||
and any(k in passed.lower() for k in hints)
|
||||
and "deepseek" not in passed.lower()
|
||||
and passed.lower() != "chat"
|
||||
)
|
||||
vision_model = env_model or cfg_model or (passed if vision_like else "") or ""
|
||||
|
||||
def _has_key(pid: str) -> bool:
|
||||
meta = providers.get(pid) or {}
|
||||
env = meta.get("api_key_env") or ""
|
||||
return bool(env) and bool(os.getenv(str(env), "").strip())
|
||||
|
||||
# 文本供应商无视觉时,回退到配置的视觉供应商(若已配密钥)
|
||||
if vision_pid in providers and not providers[vision_pid].get("supports_vision"):
|
||||
fallback = _normalize_pid(cfg_pid or "dashscope", aliases=vision_aliases)
|
||||
if fallback in providers and providers[fallback].get("supports_vision") and _has_key(fallback):
|
||||
vision_pid = fallback
|
||||
else:
|
||||
vision_pid = ""
|
||||
|
||||
if vision_pid and vision_pid in providers and providers[vision_pid].get("supports_vision"):
|
||||
return resolve_provider(vision_pid, vision_model or None)
|
||||
|
||||
pid, meta, _ = resolve_provider(provider_id, None)
|
||||
if meta.get("supports_vision"):
|
||||
return resolve_provider(pid, vision_model or None)
|
||||
|
||||
fallback = _normalize_pid(cfg_pid or "dashscope", aliases=vision_aliases)
|
||||
if fallback in providers and providers[fallback].get("supports_vision") and _has_key(fallback):
|
||||
return resolve_provider(fallback, vision_model or None)
|
||||
return pid, meta, vision_model or str(meta.get("default_model") or "")
|
||||
|
||||
|
||||
def _api_key(meta: dict[str, Any]) -> str:
|
||||
env = meta.get("api_key_env") or ""
|
||||
if not env:
|
||||
return ""
|
||||
return os.getenv(str(env), "").strip()
|
||||
|
||||
|
||||
def enhance_blueprint_with_llm(
|
||||
draft: dict[str, Any],
|
||||
prompt: str,
|
||||
excel_summary: str,
|
||||
provider_id: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
pid, meta, model_name = resolve_provider(provider_id, model)
|
||||
if pid == "heuristic":
|
||||
return draft, ["使用本地启发式蓝图(未调用大模型)"]
|
||||
|
||||
api_key = _api_key(meta)
|
||||
if not api_key:
|
||||
env = meta.get("api_key_env") or ""
|
||||
return draft, [f"未配置 {env},回退启发式蓝图"]
|
||||
|
||||
base = (meta.get("base_url") or "").rstrip("/")
|
||||
system = (
|
||||
"你是低代码 CMS 蓝图助手。只能输出 JSON 对象,字段必须兼容现有 draft 结构。"
|
||||
"field.type 仅允许: string,text,int,bigint,decimal,boolean,date,datetime,enum,json,file_ref。"
|
||||
"所有 name/slug/table/path 必须是 snake_case(小写字母数字下划线),禁止驼峰与连字符。"
|
||||
"可优化 meta.name/description、field.label、pages.title;不要发明新 type,不要输出 SQL。"
|
||||
"优先在给定 draft 上微调,保留 entities/fields/apis/pages 结构完整。"
|
||||
"若 draft.meta.ui_preset 或页面 layout.preset 为 screenshot_faithful,必须原样保留,"
|
||||
"禁止改成通用后台壳;用户未声明修改的标题/分区/筛选/图表/功能按键文案不得擅自更换。"
|
||||
"若 layout.action_labels 已给出按钮原文,必须保留;actions 顺序也尽量保留。"
|
||||
"页面形态须跟随用户需求与截图还原,禁止所有应用统一成同一种列表模板;"
|
||||
"禁止把任意业务改写成固定行业文案(如铁路沉降),除非用户或截图本身如此。"
|
||||
)
|
||||
user = {
|
||||
"user_prompt": prompt,
|
||||
"excel_summary": excel_summary,
|
||||
"draft": draft,
|
||||
"instruction": "返回完整 draft JSON(不要 markdown)",
|
||||
}
|
||||
body: dict[str, Any] = {
|
||||
"model": model_name,
|
||||
"temperature": 0.2,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": json.dumps(user, ensure_ascii=False)},
|
||||
],
|
||||
}
|
||||
if meta.get("json_mode"):
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=90.0) as client:
|
||||
resp = client.post(
|
||||
f"{base}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json=body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
content = resp.json()["choices"][0]["message"]["content"]
|
||||
text = content.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
if text.startswith("json"):
|
||||
text = text[4:].lstrip()
|
||||
enhanced = json.loads(text)
|
||||
if "version" in enhanced and "entities" in enhanced:
|
||||
enhanced.setdefault("storage", draft.get("storage"))
|
||||
enhanced.setdefault("security", draft.get("security"))
|
||||
enhanced.setdefault("apis", draft.get("apis"))
|
||||
return enhanced, [f"已使用 {meta.get('label', pid)}/{model_name} 润色蓝图"]
|
||||
if "draft" in enhanced and isinstance(enhanced["draft"], dict):
|
||||
d = enhanced["draft"]
|
||||
d.setdefault("storage", draft.get("storage"))
|
||||
d.setdefault("security", draft.get("security"))
|
||||
return d, [f"已使用 {meta.get('label', pid)}/{model_name} 润色蓝图"]
|
||||
return draft, ["LLM 返回结构无效,回退启发式"]
|
||||
except Exception as e: # noqa: BLE001
|
||||
return draft, [f"{meta.get('label', pid)} 调用失败,回退启发式: {e}"]
|
||||
|
||||
|
||||
_VISION_PROMPT = """这些是目标业务系统的界面截图。请用中文输出「可直接写入领域说明」的结构化要点,务必逐字抄录可见文案,不要臆造。
|
||||
|
||||
多图规则(重要):
|
||||
- 若多张图是**同一页面**的不同状态(展开/收起某一区、滚动到不同里程、筛选前后、空表/有数据),视为**一个界面**,合并描述,不要当成多个互斥布局。
|
||||
- 以「信息最完整」的那张为主结构(例如展开后能看见主图+监督条+表);收起态只补充「可折叠/可滚动」交互,不要因此删掉展开态才有的分区。
|
||||
- 若文案冲突,优先采用更清晰、更完整的一帧;并在备注里写「同页多状态」。
|
||||
- 仅当明显是不同页面(不同顶栏系统名/不同主导航页)时,才分页面描述。
|
||||
|
||||
按下列小标题组织(若没有的写「未见」):
|
||||
### 顶栏
|
||||
- 左侧平台抬头、中间系统名、右侧链接原文
|
||||
### 次级导航
|
||||
- 页签原文(顿号分隔)
|
||||
### 筛选条
|
||||
- 工程/业务上下文原文
|
||||
- 统计文案(个数类,原文照抄)
|
||||
- 单选选项原文(全部 / …)
|
||||
- 下拉标签原文
|
||||
- 右侧提示原文(若有)
|
||||
### 主图区
|
||||
- 左侧竖排标签原文
|
||||
- 图例系列名原文
|
||||
- 图表类型与坐标大致含义
|
||||
### 状态条/副图
|
||||
- 左侧竖排标签原文
|
||||
- 形态:单色格 / 双段条 / 其它(据实描述)
|
||||
- 条上是否显示类目文字
|
||||
- 是否可折叠/收起(若另一张图为收起态请注明)
|
||||
### 表格区
|
||||
- 左侧竖排标签、表头横标题、列名原文
|
||||
### 操作按键
|
||||
- 格式:操作:……、……
|
||||
### 多图关系(若有多张)
|
||||
- 一句话说明:同页展开/收起,或不同页
|
||||
|
||||
用户补充需求:{prompt}
|
||||
"""
|
||||
|
||||
|
||||
def understand_images(
|
||||
prompt: str,
|
||||
images: list[tuple[str, bytes, str]],
|
||||
provider_id: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""返回补充描述 + warnings。无视觉能力时回退 VISION_PROVIDER / 配置中的视觉供应商。"""
|
||||
warnings: list[str] = []
|
||||
if not images:
|
||||
return "", warnings
|
||||
|
||||
names = [n for n, _, _ in images]
|
||||
hint = (
|
||||
f"用户上传了界面截图: {', '.join(names)}。"
|
||||
"默认按截图原样还原(screenshot_faithful):顶栏、页签、筛选、工具栏功能按键原文、图表分区、表格列均与原图一致;"
|
||||
"仅用户文字明确要求修改的部分可调整。"
|
||||
)
|
||||
|
||||
pid, meta, model_name = resolve_vision_provider(provider_id, model)
|
||||
if not meta.get("supports_vision"):
|
||||
warnings.append(
|
||||
f"当前视觉通道 {pid or '无'} 不支持看图;请在 etc/llm.yaml 配置 vision.provider 并设置对应 API Key"
|
||||
)
|
||||
return hint, warnings
|
||||
|
||||
api_key = _api_key(meta)
|
||||
if not api_key:
|
||||
env = meta.get("api_key_env") or ""
|
||||
warnings.append(f"未配置 {env},截图仅作占位提示")
|
||||
return hint, warnings
|
||||
|
||||
base = (meta.get("base_url") or "").rstrip("/")
|
||||
content: list[dict[str, Any]] = [
|
||||
{"type": "text", "text": _VISION_PROMPT.format(prompt=prompt or "(无额外文字)")}
|
||||
]
|
||||
for name, raw, mime in images:
|
||||
b64 = base64.b64encode(raw).decode("ascii")
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{mime or 'image/png'};base64,{b64}"},
|
||||
}
|
||||
)
|
||||
_ = name
|
||||
|
||||
body = {
|
||||
"model": model_name,
|
||||
"temperature": 0.1,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
}
|
||||
try:
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
resp = client.post(
|
||||
f"{base}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json=body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
text = (resp.json()["choices"][0]["message"]["content"] or "").strip()
|
||||
if not text:
|
||||
return hint, warnings + ["视觉模型返回空内容"]
|
||||
return f"{hint}\n\n【截图理解】\n{text}", warnings + [
|
||||
f"已用 {meta.get('label', pid)}/{model_name} 理解截图"
|
||||
]
|
||||
except Exception as e: # noqa: BLE001
|
||||
warnings.append(f"看图失败: {e}")
|
||||
return hint, warnings
|
||||
14
ai-service/make_sample_excel.py
Normal file
14
ai-service/make_sample_excel.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""生成测试用库存 Excel"""
|
||||
from openpyxl import Workbook
|
||||
from pathlib import Path
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "库存"
|
||||
ws.append(["SKU", "商品名称", "仓库", "数量", "单价", "状态", "更新时间"])
|
||||
ws.append(["A-1001", "无线鼠标", "华东仓", 120, 59.9, "在售", "2026-07-01 10:00:00"])
|
||||
ws.append(["A-1002", "机械键盘", "华南仓", 35, 299.0, "在售", "2026-07-02 11:00:00"])
|
||||
ws.append(["B-2001", "显示器支架", "华北仓", 8, 129.0, "停售", "2026-07-03 12:00:00"])
|
||||
out = Path(__file__).resolve().parent / "sample_inventory.xlsx"
|
||||
wb.save(out)
|
||||
print(out)
|
||||
97
ai-service/preview_store.py
Normal file
97
ai-service/preview_store.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""草稿预览:未发布蓝图的内部可访问快照(供 /#/preview/{id} 与真页面截图)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_lock = threading.Lock()
|
||||
_store: dict[str, dict[str, Any]] = {}
|
||||
_TTL_SEC = 3600
|
||||
_disk = (Path(__file__).resolve().parent / ".runtime" / "preview").resolve()
|
||||
_alt_disk = (Path(__file__).resolve().parents[1] / ".runtime" / "preview").resolve()
|
||||
|
||||
|
||||
def _preview_dirs() -> list[Path]:
|
||||
dirs = [_disk]
|
||||
if _alt_disk != _disk:
|
||||
dirs.append(_alt_disk)
|
||||
return dirs
|
||||
|
||||
|
||||
def _purge() -> None:
|
||||
now = time.time()
|
||||
dead = [k for k, v in _store.items() if now - float(v.get("ts") or 0) > _TTL_SEC]
|
||||
for k in dead:
|
||||
_store.pop(k, None)
|
||||
for d in _preview_dirs():
|
||||
p = d / f"{k}.json"
|
||||
if p.is_file():
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def create_preview(
|
||||
blueprint: dict[str, Any],
|
||||
*,
|
||||
rows: list[dict[str, Any]] | None = None,
|
||||
resource: str = "",
|
||||
) -> str:
|
||||
"""写入预览包,返回 preview_id。"""
|
||||
pid = uuid.uuid4().hex[:12]
|
||||
res = resource
|
||||
if not res:
|
||||
apis = (blueprint.get("apis") or {}).get("resources") or []
|
||||
if apis and isinstance(apis[0], dict):
|
||||
res = str(apis[0].get("path") or "records").lstrip("/")
|
||||
else:
|
||||
res = "records"
|
||||
pack = {
|
||||
"id": pid,
|
||||
"ts": time.time(),
|
||||
"blueprint": blueprint,
|
||||
"rows": list(rows or [])[:800],
|
||||
"resource": res,
|
||||
}
|
||||
with _lock:
|
||||
_purge()
|
||||
_store[pid] = pack
|
||||
for d in _preview_dirs():
|
||||
try:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / f"{pid}.json").write_text(
|
||||
json.dumps(pack, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
return pid
|
||||
|
||||
|
||||
def get_preview(preview_id: str) -> dict[str, Any] | None:
|
||||
pid = (preview_id or "").strip()
|
||||
if not pid:
|
||||
return None
|
||||
with _lock:
|
||||
_purge()
|
||||
pack = _store.get(pid)
|
||||
if pack:
|
||||
return pack
|
||||
for d in _preview_dirs():
|
||||
p = d / f"{pid}.json"
|
||||
if not p.is_file():
|
||||
continue
|
||||
try:
|
||||
pack = json.loads(p.read_text(encoding="utf-8"))
|
||||
if isinstance(pack, dict):
|
||||
with _lock:
|
||||
_store[pid] = pack
|
||||
return pack
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
return None
|
||||
7
ai-service/requirements.txt
Normal file
7
ai-service/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
python-multipart==0.0.17
|
||||
openpyxl==3.1.5
|
||||
httpx==0.28.1
|
||||
python-dotenv>=1.1.0
|
||||
PyYAML>=6.0.1
|
||||
BIN
ai-service/sample_inventory.xlsx
Normal file
BIN
ai-service/sample_inventory.xlsx
Normal file
Binary file not shown.
125
ai-service/shot_worker.py
Normal file
125
ai-service/shot_worker.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Standalone Playwright screenshot worker (fresh process, Windows-safe).
|
||||
|
||||
Invoked by fidelity_loop.capture_real_app_screenshot — never import uvicorn here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _force_proactor() -> None:
|
||||
if sys.platform == "win32":
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
|
||||
|
||||
async def _shot_async(payload: dict) -> tuple[bytes | None, str, str | None]:
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
draft = payload.get("draft") or {}
|
||||
rows = payload.get("rows") or []
|
||||
web = (os.getenv("WEB_BASE") or "http://127.0.0.1:5173").rstrip("/")
|
||||
goto_timeout = int(os.getenv("FIDELITY_GOTO_TIMEOUT_MS") or "45000")
|
||||
slug = str(((draft.get("meta") or {}).get("slug")) or "preview")
|
||||
preview_id = str(payload.get("preview_id") or "preview")
|
||||
inject_payload = {
|
||||
"ok": True,
|
||||
"id": preview_id,
|
||||
"blueprint": draft,
|
||||
"rows": rows,
|
||||
"resource": payload.get("resource") or "records",
|
||||
}
|
||||
inject_js = (
|
||||
"try{localStorage.removeItem('ajz_session');}catch(e){}"
|
||||
"window.__AJZ_PREVIEW__ = "
|
||||
+ json.dumps(inject_payload, ensure_ascii=False)
|
||||
+ ";"
|
||||
)
|
||||
target = f"{web}/#/preview/{preview_id}"
|
||||
|
||||
def fmt(err: BaseException) -> str:
|
||||
msg = str(err).strip() or repr(err)
|
||||
return msg.replace("\n", " ")[:500]
|
||||
|
||||
try:
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
try:
|
||||
page = await browser.new_page(viewport={"width": 1600, "height": 1100})
|
||||
await page.add_init_script(inject_js)
|
||||
|
||||
async def fulfill(route):
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json; charset=utf-8",
|
||||
body=json.dumps(inject_payload, ensure_ascii=False).encode("utf-8"),
|
||||
)
|
||||
|
||||
await page.route("**/api/v1/preview/**", fulfill)
|
||||
await page.route("**/ai/api/v1/preview/**", fulfill)
|
||||
await page.goto(target, wait_until="domcontentloaded", timeout=goto_timeout)
|
||||
await page.evaluate(inject_js)
|
||||
try:
|
||||
await page.wait_for_selector(
|
||||
".sf-shell-bar, .sf-root, .gen-app-ops, .gen-app, .sf-line-wrap",
|
||||
timeout=45000,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
snip = (await page.locator("body").inner_text(timeout=2000))[:240]
|
||||
except Exception:
|
||||
snip = page.url
|
||||
raise RuntimeError(
|
||||
f"preview shell missing. page={page.url} text={snip!r}"
|
||||
) from None
|
||||
login_btns = page.locator("button").filter(has_text="登录")
|
||||
shell = page.locator(".sf-shell-bar, .sf-root, .gen-app")
|
||||
if await login_btns.count() and not await shell.count():
|
||||
raise RuntimeError("preview fell back to login")
|
||||
await page.wait_for_timeout(2500)
|
||||
if await page.locator(".gen-app").count():
|
||||
png = await page.locator(".gen-app").first.screenshot(type="png")
|
||||
else:
|
||||
png = await page.screenshot(type="png", full_page=True)
|
||||
finally:
|
||||
await browser.close()
|
||||
return (
|
||||
png,
|
||||
f"已截取草稿预览(/#/preview/{preview_id},注入数据无需再拉 AI)参与对比",
|
||||
slug,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return None, f"真页面截图失败: preview={fmt(e)}(目标 {target})", slug
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_force_proactor()
|
||||
inp = Path(os.environ["FIDELITY_SHOT_INPUT"])
|
||||
outp = Path(os.environ["FIDELITY_SHOT_OUTPUT"])
|
||||
payload = json.loads(inp.read_text(encoding="utf-8"))
|
||||
png, note, slug = asyncio.run(_shot_async(payload))
|
||||
out_dir = os.getenv("FIDELITY_SHOT_DIR") or ""
|
||||
if png and out_dir:
|
||||
d = Path(out_dir)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / f"preview_{payload.get('preview_id') or 'x'}.png").write_bytes(png)
|
||||
outp.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"png_b64": base64.b64encode(png).decode("ascii") if png else None,
|
||||
"note": note,
|
||||
"slug": slug,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return 0 if png else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
170
ai-service/url_capture.py
Normal file
170
ai-service/url_capture.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""解析首页抓包 url.config:key/value 成对,对应菜单/图表/统计等接口回包。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any
|
||||
|
||||
|
||||
class _MenuHTMLParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.items: list[str] = []
|
||||
self._in_a = False
|
||||
self._buf = ""
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
if tag.lower() == "a":
|
||||
self._in_a = True
|
||||
self._buf = ""
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag.lower() == "a" and self._in_a:
|
||||
text = re.sub(r"\s+", " ", self._buf).strip()
|
||||
if text:
|
||||
self.items.append(text)
|
||||
self._in_a = False
|
||||
self._buf = ""
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._in_a:
|
||||
self._buf += data
|
||||
|
||||
|
||||
def parse_url_config(content: bytes | str) -> dict[str, Any]:
|
||||
"""
|
||||
格式示例:
|
||||
key https://.../left!newleftmenu.action?...
|
||||
value <ul>...</ul>
|
||||
|
||||
key https://.../hightchartCjlAndJianDu.action?...
|
||||
value { ...json... }
|
||||
"""
|
||||
if isinstance(content, bytes):
|
||||
text = content.decode("utf-8-sig", errors="ignore")
|
||||
if not text.strip():
|
||||
for enc in ("gb18030", "utf-16", "utf-16-le"):
|
||||
try:
|
||||
text = content.decode(enc)
|
||||
if text.strip():
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
else:
|
||||
text = content
|
||||
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
# 按「行首 key 」切开
|
||||
parts = re.split(r"(?m)^key\s+", text)
|
||||
pairs: list[dict[str, Any]] = []
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
# 第一行 URL,其后 value 段
|
||||
if "\n" in part:
|
||||
first, rest = part.split("\n", 1)
|
||||
else:
|
||||
first, rest = part, ""
|
||||
url = first.strip()
|
||||
m = re.match(r"(?is)^value\s*(.*)\Z", rest.strip(), flags=re.S)
|
||||
body = (m.group(1) if m else rest).strip()
|
||||
# 去掉仅有的空白
|
||||
if not body:
|
||||
body = ""
|
||||
action = ""
|
||||
am = re.search(r"!([a-zA-Z0-9_]+)\.action", url)
|
||||
if am:
|
||||
action = am.group(1)
|
||||
else:
|
||||
am = re.search(r"/([a-zA-Z0-9_!]+)\.action", url)
|
||||
if am:
|
||||
action = am.group(1).split("!")[-1]
|
||||
kind = "empty"
|
||||
parsed: Any = None
|
||||
if not body:
|
||||
kind = "empty"
|
||||
elif body.lstrip().startswith("{") or body.lstrip().startswith("["):
|
||||
kind = "json"
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
kind = "json_invalid"
|
||||
parsed = None
|
||||
elif "<" in body and ">" in body:
|
||||
kind = "html"
|
||||
parsed = body
|
||||
else:
|
||||
kind = "text"
|
||||
parsed = body
|
||||
pairs.append(
|
||||
{
|
||||
"url": url,
|
||||
"action": action,
|
||||
"kind": kind,
|
||||
"body": body,
|
||||
"parsed": parsed,
|
||||
"empty": kind == "empty",
|
||||
}
|
||||
)
|
||||
|
||||
nav_items: list[str] = []
|
||||
chart_json: dict[str, Any] | None = None
|
||||
stats_json: dict[str, Any] | None = None
|
||||
exceed_json: Any = None
|
||||
warnings: list[str] = []
|
||||
|
||||
for p in pairs:
|
||||
act = (p.get("action") or "").lower()
|
||||
url = p.get("url") or ""
|
||||
if p["kind"] == "html" and ("leftmenu" in act or "left" in url or "menu" in act):
|
||||
parser = _MenuHTMLParser()
|
||||
try:
|
||||
parser.feed(str(p["parsed"] or ""))
|
||||
parser.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if parser.items:
|
||||
nav_items = parser.items
|
||||
elif p["kind"] == "json" and isinstance(p["parsed"], dict):
|
||||
data = p["parsed"]
|
||||
if "mcljChartData" in data or "cljdChartData" in data:
|
||||
chart_json = data
|
||||
elif any(k in data for k in ("yqwccds", "xzcxcds", "nodisposecount", "bdlength")):
|
||||
stats_json = data
|
||||
elif "gonghou" in act.lower() or "cjlcx" in act.lower() or "cx" in act:
|
||||
exceed_json = data
|
||||
elif chart_json is None and any(
|
||||
isinstance(v, (list, dict)) for v in data.values()
|
||||
):
|
||||
# 兜底:第一个复杂 JSON 当图表
|
||||
chart_json = data
|
||||
elif p["empty"] and ("gonghou" in act.lower() or "cjlcx" in act.lower() or "CX" in url):
|
||||
warnings.append(
|
||||
f"接口 {p.get('action') or url} 返回为空:"
|
||||
"超限测点表无抓包数据,将由主图表 JSON 按超限条件推导"
|
||||
)
|
||||
elif p["empty"]:
|
||||
warnings.append(f"接口 {p.get('action') or url} 返回为空,已跳过")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"pairs": [
|
||||
{
|
||||
"url": p["url"],
|
||||
"action": p["action"],
|
||||
"kind": p["kind"],
|
||||
"empty": p["empty"],
|
||||
"body_len": len(p.get("body") or ""),
|
||||
}
|
||||
for p in pairs
|
||||
],
|
||||
"nav_items": nav_items,
|
||||
"chart_json": chart_json,
|
||||
"stats_json": stats_json,
|
||||
"exceed_json": exceed_json,
|
||||
"warnings": warnings,
|
||||
"source": "url_config",
|
||||
}
|
||||
Reference in New Issue
Block a user