"""截图还原迭代闭环(通用,不绑定行业)。 流程: 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