chore: initial commit of ai site platform

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-07-31 10:31:17 +08:00
commit 4ca82fb58a
203 changed files with 45745 additions and 0 deletions

6
ai-service/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
__pycache__
*.pyc
.venv
sample_inventory.xlsx
*.md

11
ai-service/Dockerfile Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View File

@@ -0,0 +1,88 @@
# AI / LLM 供应商配置(独立文件,勿把密钥写进本文件)
# 密钥放项目根 .envDEEPSEEK_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
View 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=truescore 最高 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
View File

@@ -0,0 +1,173 @@
"""蓝图生成过程日志:控制台 + 落盘,便于对账。
环境变量:
- GENERATE_LOG_DIR日志目录默认仓库 .runtime/logs/generate
- GENERATE_LOG_LEVELDEBUG/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()

View 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 Blueprintentities / apis / pages / widgets
适用于任意行业,禁止把所有应用生成成同一种通用绿主题 CRUD 壳。
还原原则(全局):
- 有截图/HTML默认一模一样meta.ui_preset=screenshot_faithfuldashboard 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。
- RESTGET/POST/PUT/DELETE看板 widgetskpi/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_daysvalue_field+secondary_fieldcycle_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
View 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
View 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

View 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)

View 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

View 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

Binary file not shown.

125
ai-service/shot_worker.py Normal file
View 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
View File

@@ -0,0 +1,170 @@
"""解析首页抓包 url.configkey/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",
}