chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user