Files
ai_site/ai-service/app.py
2026-07-31 10:19:22 +08:00

1808 lines
75 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
AI 蓝图生成服务:描述 + Excel(+可选图片) → AppBlueprint draft
无 LLM Key 时走启发式规则,保证可离线测试。
领域无关:不写死库存/沉降等业务字段,由表头规范化 + 类型推断 + 蓝图 widgets 驱动。
"""
from __future__ import annotations
import asyncio
import hashlib
import io
import json
import re
import unicodedata
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, Form, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from openpyxl import load_workbook
from generation_rules import (
DEFAULT_ACTION_LABELS,
compose_effective_prompt,
parse_action_bar,
wants_screenshot_layout,
)
from html_layout import layout_summary_text, merge_layout_into_ui_hints, parse_layout_file
from llm import enhance_blueprint_with_llm, list_providers, load_ai_config, understand_images
from fidelity_loop import run_fidelity_loop
from generate_log import GenerateTrace, ensure_logging
from demo_fixtures import file_response, fixtures_manifest, read_prompt_text
from preview_store import create_preview, get_preview
import os
ensure_logging()
# 优先加载仓库根 .env其次 ai-service/.env容错编码避免整服务起不来
_root = Path(__file__).resolve().parents[1]
def _safe_load_dotenv(path: Path, *, override: bool = False) -> None:
if not path.is_file():
return
raw = path.read_bytes()
text: str | None = None
for enc in ("utf-8-sig", "utf-8", "gbk", "cp936", "latin-1"):
try:
text = raw.decode(enc)
break
except UnicodeDecodeError:
continue
if text is None:
print(f"WARN: cannot decode env file {path}, skip")
return
for line in text.splitlines():
s = line.strip()
if not s or s.startswith("#") or "=" not in s:
continue
key, _, val = s.partition("=")
key = key.strip()
val = val.strip().strip('"').strip("'")
if not key:
continue
if override or key not in os.environ:
os.environ[key] = val
# 若曾用非 utf-8 读入,尝试写回 utf-8避免下次再炸
try:
if raw[:3] != b"\xef\xbb\xbf":
# 仅当原文件不是合法 utf-8 时重写
try:
raw.decode("utf-8")
except UnicodeDecodeError:
path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
print(f"WARN: rewrote {path} as utf-8")
except OSError:
pass
_safe_load_dotenv(_root / ".env")
_safe_load_dotenv(Path(__file__).resolve().parent / ".env", override=True)
app = FastAPI(title="aijianzhan-ai", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
_SYSTEM_FIELDS = {"id", "tenant_id", "created_by", "created_at", "updated_at"}
def slugify(text: str, fallback: str = "app") -> str:
"""通用标识符:驼峰/点号 → snake_case纯中文用稳定短哈希无业务词表。"""
original = text or ""
text = unicodedata.normalize("NFKC", original)
text = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", text)
text = text.lower()
text = text.replace("-", "_").replace(".", "_")
text = re.sub(r"[^a-z0-9_]+", "_", text)
text = re.sub(r"_+", "_", text).strip("_")
if not text or not re.match(r"^[a-z]", text):
# 列名用 col_N应用 slug 用短哈希,避免依赖中文词表
if re.match(r"^col_\d+$", fallback):
text = fallback
else:
h = hashlib.sha1(original.encode("utf-8")).hexdigest()[:8]
text = f"{fallback}_{h}"
return text[:48]
def to_field_name(header: str, idx: int) -> str:
raw = slugify(header, fallback=f"col_{idx}")
return raw if re.match(r"^[a-z]", raw) else f"col_{idx}"
def infer_app_name(prompt: str) -> str:
"""从需求描述提取应用显示名。"""
p = prompt or ""
for pat in (
r"应用名称[推荐为是::\s]*[「『\"]?([^」』\"\n]{2,32})",
r"[「『](.+?)[」』]",
r"生成[一个]?(?:业务)?应用[:\s]*([^\n]{2,32})",
):
m = re.search(pat, p)
if m:
name = m.group(1).strip()
if name and name not in {"Excel", "xlsx", "CSV"}:
return name[:32]
for line in p.splitlines():
line = line.strip("# ").strip()
if 4 <= len(line) <= 32 and not line.startswith("http"):
return line[:32]
return "智能应用"
def infer_item_label(prompt: str, sheet_label: str) -> str:
"""业务对象称呼:提示词显式指定 > Excel 表名 >「记录」。"""
p = prompt or ""
m = re.search(r"(?:对象|实体|记录)名[称]?[推荐为是::\s]*([^\n]{1,16})", p)
if m:
return m.group(1).strip()[:16]
if sheet_label and re.search(r"[\u4e00-\u9fff]", sheet_label):
lab = re.sub(r"(表|列表|台账|数据|信息)$", "", sheet_label).strip()
if lab and lab.lower() not in {"sheet1", "sheet"}:
return lab[:16]
if sheet_label and sheet_label.lower() not in {"sheet1", "sheet", "数据"}:
return sheet_label[:16]
return "记录"
def parse_ui_hints(prompt: str, image_count: int, html_count: int = 0) -> dict[str, Any]:
"""从用户需求提取展示元信息;截图/HTML 忠实策略由 generation_rules 决定。"""
p = prompt or ""
hints: dict[str, Any] = {"ui": {}}
if wants_screenshot_layout(p, image_count, html_count):
hints["ui_preset"] = "screenshot_faithful"
m = re.search(r"平台抬头[:]\s*(.+)", p)
if m:
hints["ui"]["platform_title"] = m.group(1).strip().splitlines()[0][:64]
m = re.search(r"系统名称[:]\s*(.+)", p)
if m:
hints["app_name"] = m.group(1).strip().splitlines()[0][:64]
m = re.search(r"应用名称推荐[:]\s*(.+)", p)
if m and not hints.get("app_name"):
hints["app_name"] = m.group(1).strip().splitlines()[0][:64]
m = re.search(r"推荐 slug[:]\s*`?([a-z][a-z0-9_]{1,47})`?", p)
if m:
hints["slug"] = m.group(1)
m = re.search(r"工程上下文(?:示例)?[:]\s*(.+)", p)
if m:
ctx = m.group(1).strip().splitlines()[0]
ctx = re.split(r"[(]", ctx, 1)[0].strip()
hints["project_context"] = ctx[:120]
# 常见「XX平台」抬头
if "platform_title" not in hints["ui"]:
m = re.search(r"([^\n]{2,20}管理平台)", p)
if m:
hints["ui"]["platform_title"] = m.group(1).strip()
# 仅当提示词明文写出时才采纳(不写死行业)
if "点击" in p and "查看" in p:
m = re.search(r"(点击[^。\n]{2,40})", p)
if m:
hints["ui"]["filter_hint"] = m.group(1).strip()
# 单选选项:全部 / A / B / C 或 **A / B / C**
m = re.search(r"(?:全部\s*[/]\s*)([^。\n]{2,40})", p)
if m and ("单选" in p or "筛选" in p or "类型" in p):
opts = [x.strip().strip("*` ") for x in re.split(r"[//、,,]", m.group(1)) if x.strip()]
opts = [o for o in opts if o and o != "全部" and len(o) <= 12][:8]
if len(opts) >= 2:
hints["ui"]["section_options"] = opts
hints["filter_style"] = "section_radios"
# 页面标题仅匹配「标题xxx」或明确业务页名行
for key, pat in (
("list_title", r"(?:列表标题|列表页)[:]\s*(.+)"),
("create_title", r"(?:新增页|表单标题|创建页)[:]\s*(.+)"),
("dash_title", r"(?:看板标题|看板页)[:]\s*(.+)"),
):
m = re.search(pat, p)
if m:
hints[key] = m.group(1).strip().splitlines()[0][:64]
action_bar = parse_action_bar(p)
if action_bar:
hints["actions"] = action_bar["actions"]
hints["action_labels"] = action_bar["action_labels"]
return hints
def wants_dashboard(prompt: str, image_count: int, html_count: int = 0) -> bool:
p = prompt or ""
keys = ("图表", "看板", "统计", "概览", "dashboard", "折线", "曲线", "纵断", "KPI", "可视化")
if any(k.lower() in p.lower() if k.isascii() else k in p for k in keys):
return True
return image_count > 0 or html_count > 0
def wants_line_chart(prompt: str) -> bool:
p = prompt or ""
return any(k in p for k in ("折线", "曲线", "纵断", "趋势", "line chart", "时序"))
def build_dashboard_widgets(
fields: list[dict[str, Any]],
filters: list[str],
list_cols: list[str],
entity_name: str,
item_label: str,
prompt: str,
) -> list[dict[str, Any]]:
"""按字段类型与提示词组装 widgets标题优先来自提示词/HTML否则用通用名。"""
usable = [f for f in fields if f["name"] not in _SYSTEM_FIELDS]
names = {f["name"] for f in usable}
numeric = [f for f in usable if f["type"] in {"int", "bigint", "decimal"}]
categorical = [
f["name"]
for f in usable
if f.get("enum_values") or f["type"] == "enum" or f["name"] in filters
]
x_candidates = [f["name"] for f in usable if f["type"] in {"string", "enum", "date", "datetime"}]
p = prompt or ""
def pick_title(*cands: str, default: str) -> str:
for c in cands:
if c and c in p:
# 取含该关键词的短句
m = re.search(rf"([^\n:]{{0,10}}{re.escape(c)}[^\n:]{{0,16}})", p)
if m:
t = m.group(1).strip(" -—|")
if 2 <= len(t) <= 32:
return t
return c
return default
# 多数值 + 类目/编码轴 → 堆叠看板(图 + 可选状态条 + 表),不绑定行业
stacked = (
len(numeric) >= 2
and (x_candidates or filters)
and (
wants_line_chart(p)
or "看板" in p
or "示意图" in p
or "监督" in p
or "status_strip" in p
or bool({"chainage", "dkilo"} & names)
)
)
if stacked:
x_field = next((n for n in ("chainage", "dkilo", "code", "name") if n in names), None)
if not x_field:
x_field = x_candidates[0] if x_candidates else (list_cols[0] if list_cols else "id")
metrics = [f["name"] for f in numeric[:4]]
# 优先常见度量名(若存在)
preferred = [n for n in (
"value", "cjl_value", "amount", "qty", "score", "count",
"design_value", "design_settlement_mm", "cum_value", "cum_settlement_mm",
"pred_value", "pred_settlement_mm",
) if n in names]
if preferred:
# 主度量优先观测值,设计/累积作辅系列
primary = next(
(n for n in ("cjl_value", "value", "cum_value", "cum_settlement_mm") if n in preferred),
preferred[0],
)
rest = [n for n in preferred if n != primary][:3]
metrics = [primary] + rest
widgets: list[dict[str, Any]] = [
{
"type": "line_chart",
"title": pick_title("示意图", "趋势图", "曲线", default="趋势图"),
"x_field": x_field,
"group_by": x_field,
"metrics": metrics,
"metric": metrics[0] if metrics else "count",
"y_unit": "mm" if any("mm" in n or n.endswith("_value") for n in metrics) else "",
"entity": entity_name,
},
]
if "before_day" in names and "next_day" in names:
widgets.append(
{
"type": "status_strip",
"title": pick_title("监督", "状态条", "进度", default="状态"),
"label_field": x_field,
"value_field": "before_day",
"secondary_field": "next_day",
"warn_field": next((n for n in ("overdue_days", "exceed_days", "warn_flag") if n in names), ""),
"variant": "stacked_days",
"cycle_days": 30,
"entity": entity_name,
}
)
elif "supervise_days" in names or "status_days" in names or "days" in names:
value_f = next((n for n in ("supervise_days", "status_days", "days") if n in names), "supervise_days")
widgets.append(
{
"type": "status_strip",
"title": pick_title("状态条", "进度", "监督", default="状态"),
"label_field": x_field,
"value_field": value_f,
"warn_field": next((n for n in ("overdue_days", "exceed_days", "warn_flag") if n in names), ""),
"entity": entity_name,
}
)
table_cols = list_cols[:6] or [f["name"] for f in usable[:6]]
widgets.append(
{
"type": "table",
"title": pick_title("明细", "列表", "异常", default=f"{item_label}明细"),
"entity": entity_name,
"columns": table_cols,
"filter_field": next((n for n in ("exceed_mm", "status", "flag") if n in names), ""),
"filter_op": "gt" if "exceed_mm" in names else "eq",
"filter_value": 0 if "exceed_mm" in names else "",
}
)
return widgets
widgets = [
{"type": "kpi", "title": f"{item_label}数量", "metric": "count", "entity": entity_name},
]
if wants_line_chart(prompt) or (len(numeric) >= 2 and x_candidates):
x_field = x_candidates[0] if x_candidates else (list_cols[0] if list_cols else "id")
metrics = [f["name"] for f in numeric[:4]]
widgets.append(
{
"type": "line_chart",
"title": "趋势图",
"x_field": x_field,
"group_by": x_field,
"metrics": metrics,
"metric": metrics[0] if metrics else "count",
"entity": entity_name,
}
)
g = categorical[0] if categorical else (filters[0] if filters else None)
if g:
chart_type = "bar_chart" if wants_line_chart(prompt) else "pie_chart"
widgets.append(
{
"type": chart_type,
"title": "分布",
"metric": "count",
"group_by": g,
"entity": entity_name,
}
)
elif not any(w["type"] == "line_chart" for w in widgets):
g2 = list_cols[0] if list_cols else "id"
widgets.append(
{
"type": "pie_chart",
"title": "分布",
"metric": "count",
"group_by": g2,
"entity": entity_name,
}
)
return widgets
_ALLOWED_TYPES = {
"string", "text", "int", "bigint", "decimal", "boolean",
"date", "datetime", "enum", "json", "file_ref",
}
_TYPE_ALIAS = {
"float": "decimal", "float32": "decimal", "float64": "decimal",
"double": "decimal", "number": "decimal", "numeric": "decimal",
"integer": "int", "int32": "int", "int64": "bigint", "long": "bigint",
"bool": "boolean", "varchar": "string", "str": "string",
"timestamp": "datetime", "timestamptz": "datetime",
}
def looks_like_faithful_dashboard(draft: dict[str, Any]) -> bool:
"""看板是否应按截图壳渲染:已标 preset或为「图+条/表」分区结构(不限行业)。"""
for p in draft.get("pages") or []:
if not isinstance(p, dict) or p.get("type") != "dashboard":
continue
lay = p.get("layout") or {}
if not isinstance(lay, dict):
continue
if lay.get("preset") in {"screenshot_faithful", "ops_monitor"}:
return True
widgets = lay.get("widgets") or []
types = {str(w.get("type") or "") for w in widgets if isinstance(w, dict)}
# 截图类看板常见:主图 +(状态条或明细表),而非仅 KPI
if "line_chart" in types and ("status_strip" in types or "table" in types):
return True
if "bar_chart" in types and "table" in types and "kpi" in types and len(types) >= 3:
return True
return False
def apply_screenshot_faithful(draft: dict[str, Any], *, force: bool = False) -> dict[str, Any]:
"""通用:写入 ui_preset / layout.preset并补齐主图/监督条默认,避免主图区空白。"""
if not force and not looks_like_faithful_dashboard(draft):
return draft
meta = draft.setdefault("meta", {})
meta["ui_preset"] = "screenshot_faithful"
ui = meta.get("ui") if isinstance(meta.get("ui"), dict) else {}
meta["ui"] = ui
# 从主实体字段推断主图取值/颜色/设计曲线(领域无关:按常见后缀与颜色字段)
fields: list[dict[str, Any]] = []
for ent in draft.get("entities") or []:
if isinstance(ent, dict) and ent.get("fields"):
fields = list(ent["fields"])
break
names = {str(f.get("name") or "") for f in fields if isinstance(f, dict)}
def first(*cands: str) -> str:
for c in cands:
if c in names:
return c
return ""
if not ui.get("value_field"):
vf = first("cjl_value", "value", "cum_value", "cum_settlement_mm", "amount", "qty", "score")
if vf:
ui["value_field"] = vf
if not ui.get("color_field"):
cf = first("cjl_color", "color", "mark_color")
if cf:
ui["color_field"] = cf
if not ui.get("design_field"):
df = first("design_value", "design_settlement_mm", "target", "design")
if df:
ui["design_field"] = df
if not ui.get("category_field"):
cat = first("section_type", "category", "type", "kind")
if cat:
ui["category_field"] = cat
# 截图还原默认着色测点图(有取值字段时);显式 line/multi_series 除外
style = str(ui.get("chart_style") or "").lower()
if style not in {"line", "multi_series", "line_chart", "section_marks", "colored_marks"}:
if ui.get("value_field") or first("cjl_value", "value"):
ui["chart_style"] = "section_marks"
if not ui.get("y_unit") and (
ui.get("value_field", "").endswith("_mm")
or "settlement" in ui.get("value_field", "")
or ui.get("value_field") in {"cjl_value", "cum_value", "design_value"}
):
ui["y_unit"] = "mm"
if not ui.get("y_axis_label") and ui.get("y_unit") == "mm":
ui["y_axis_label"] = "沉降量"
if "invert_y" not in ui and ui.get("chart_style") in {"section_marks", "colored_marks"}:
ui["invert_y"] = True
if not ui.get("legend_items"):
# 用单选选项生成占位图例色,避免图例全空
radios = ui.get("filter_radios") or ui.get("section_options") or []
palette = ["#8000FF", "#214080", "#868e96", "#0f766e", "#e8590c"]
items = []
for i, lab in enumerate(radios):
if not lab or lab in {"全部", "所有", "all", "All"}:
continue
items.append({"label": f"{lab}累计", "color": palette[i % len(palette)]})
if ui.get("design_field"):
items.append({"label": "设计曲线", "color": ui.get("design_series_color") or "#e8590c"})
if items:
ui["legend_items"] = items
for p in draft.get("pages") or []:
if not isinstance(p, dict):
continue
if p.get("type") == "dashboard":
lay = p.setdefault("layout", {})
if isinstance(lay, dict):
lay["preset"] = "screenshot_faithful"
# 截图主区通常无顶部 KPI 卡片条;有图/条/表分区时去掉 kpi 以免破坏还原
widgets = [w for w in (lay.get("widgets") or []) if isinstance(w, dict)]
types = {w.get("type") for w in widgets}
if ("line_chart" in types or "bar_chart" in types) and (
"status_strip" in types or "table" in types
):
widgets = [w for w in widgets if w.get("type") != "kpi"]
# 补齐主图/监督条字段,避免渲染侧整段跳过
x_fallback = first("chainage", "dkilo", "code", "name") or (
next(
(
str(f.get("name"))
for f in fields
if isinstance(f, dict)
and f.get("type") in {"string", "enum", "date"}
and f.get("name") not in _SYSTEM_FIELDS
),
"id",
)
)
for w in widgets:
if w.get("type") in {"line_chart", "bar_chart"}:
w.setdefault("x_field", x_fallback)
w.setdefault("group_by", w.get("x_field") or x_fallback)
if not w.get("metrics") and not w.get("metric"):
m0 = ui.get("value_field") or first(
"cjl_value", "value", "cum_value", "cum_settlement_mm"
)
if m0:
w["metric"] = m0
w["metrics"] = [m0]
if ui.get("design_field"):
w["metrics"] = [m0, ui["design_field"]]
if ui.get("y_unit"):
w.setdefault("y_unit", ui["y_unit"])
if w.get("type") == "status_strip":
w.setdefault("label_field", x_fallback)
if "before_day" in names and "next_day" in names:
w.setdefault("value_field", "before_day")
w.setdefault("secondary_field", "next_day")
w.setdefault("variant", "stacked_days")
w.setdefault("cycle_days", 30)
elif not w.get("value_field"):
w["value_field"] = first(
"supervise_days", "status_days", "before_day", "days"
) or "supervise_days"
lay["widgets"] = widgets
title = str(p.get("title") or "")
# 去掉套话「概览」,保留业务名;不改写成固定行业标题
if title.endswith("概览") and len(title) > 2:
p["title"] = title[: -len("概览")].rstrip() or title
if not str(p["title"]).endswith("看板"):
p["title"] = f"{p['title']}看板" if p["title"] else "看板"
if p.get("type") == "list":
lay = p.setdefault("layout", {})
if isinstance(lay, dict) and not lay.get("filter_style"):
if ui.get("filter_style") or ui.get("section_options") or ui.get("filter_radios"):
lay["filter_style"] = ui.get("filter_style") or "section_radios"
return draft
def harden_draft(draft: dict[str, Any], fallback: dict[str, Any]) -> dict[str, Any]:
"""发布前收口:非法 type/空结构回退,避免 LLM 润色后无法 publish。"""
if not isinstance(draft, dict) or not draft.get("entities"):
return fallback
draft.setdefault("version", "1.0")
draft.setdefault("storage", fallback.get("storage") or {"mode": "schema_per_app", "engine": "postgres"})
draft.setdefault("security", fallback.get("security") or {"visibility": "private", "roles": [], "row_policies": []})
draft.setdefault("apis", fallback.get("apis"))
draft.setdefault("pages", fallback.get("pages") or [])
meta = draft.setdefault("meta", {})
if not meta.get("slug"):
meta["slug"] = (fallback.get("meta") or {}).get("slug") or "app"
meta["slug"] = slugify(str(meta["slug"]), fallback="app")
if not meta.get("name"):
meta["name"] = (fallback.get("meta") or {}).get("name") or meta["slug"]
entity_map: dict[str, str] = {}
for ent in draft["entities"]:
old = str(ent.get("name") or "")
if not ent.get("name"):
ent["name"] = "record"
ent["name"] = slugify(str(ent["name"]), fallback="record")
ent["table"] = slugify(str(ent.get("table") or ent["name"]), fallback=ent["name"])
if old:
entity_map[old] = ent["name"]
entity_map[slugify(old, fallback=ent["name"])] = ent["name"]
ent.setdefault("primary_key", "id")
fields = ent.get("fields") or []
if not fields:
return fallback
names = set()
for i, f in enumerate(fields):
fname = slugify(str(f.get("name") or f"col_{i}"), fallback=f"col_{i}")
if fname in names:
fname = f"{fname}_{i}"
names.add(fname)
f["name"] = fname
t = str(f.get("type") or "string").lower().strip()
t = _TYPE_ALIAS.get(t, t)
if t not in _ALLOWED_TYPES:
t = "string"
f["type"] = t
if not any(f.get("name") == ent["primary_key"] for f in fields):
fields.insert(0, {
"name": ent["primary_key"],
"label": "ID",
"type": "bigint",
"nullable": False,
"ui": {"widget": "hidden", "listable": False},
})
ent["fields"] = fields
def resolve_entity(ref: Any) -> str:
key = str(ref or "")
if key in entity_map:
return entity_map[key]
sn = slugify(key, fallback="")
if sn in entity_map:
return entity_map[sn]
return sn or key
apis = draft.get("apis") or {}
for r in apis.get("resources") or []:
if isinstance(r, dict):
r["entity"] = resolve_entity(r.get("entity"))
path = str(r.get("path") or "").lstrip("/")
r["path"] = "/" + (slugify(path, fallback="items") if path else "items")
for p in draft.get("pages") or []:
if isinstance(p, dict) and p.get("entity"):
p["entity"] = resolve_entity(p.get("entity"))
for rp in (draft.get("security") or {}).get("row_policies") or []:
if isinstance(rp, dict) and rp.get("entity"):
rp["entity"] = resolve_entity(rp.get("entity"))
# 截图忠实LLM 不得抹掉;有图或分区结构时强制恢复(不限行业)
fb_meta = fallback.get("meta") or {}
fb_src = fb_meta.get("source") or {}
force_faithful = (
fb_meta.get("ui_preset") == "screenshot_faithful"
or fb_src.get("screenshot_faithful") is True
or bool(fb_src.get("image_refs"))
or looks_like_faithful_dashboard(fallback)
or looks_like_faithful_dashboard(draft)
)
if force_faithful:
if fb_meta.get("platform_title"):
meta["platform_title"] = fb_meta["platform_title"]
if fb_meta.get("ui"):
meta["ui"] = {**(fb_meta.get("ui") or {}), **(meta.get("ui") or {})}
if fb_meta.get("project_context") and not meta.get("project_context"):
meta["project_context"] = fb_meta["project_context"]
# 保留用户/启发式名称,禁止被润色成套话
fb_name = str(fb_meta.get("name") or "")
cur_name = str(meta.get("name") or "")
if fb_name and (not cur_name or "数据管理" in cur_name or cur_name.endswith("概览")):
meta["name"] = fb_name
apply_screenshot_faithful(draft, force=True)
# 功能按键文案LLM 不得抹掉用户/截图还原的 labels
for p, fp in zip(draft.get("pages") or [], fallback.get("pages") or []):
if not isinstance(p, dict) or not isinstance(fp, dict):
continue
flay = fp.get("layout") or {}
if not isinstance(flay, dict):
continue
lay = p.setdefault("layout", {})
if not isinstance(lay, dict):
continue
if flay.get("action_labels"):
merged = dict(flay.get("action_labels") or {})
merged.update(lay.get("action_labels") or {})
for k, v in (flay.get("action_labels") or {}).items():
merged[k] = v
lay["action_labels"] = merged
if flay.get("actions") and (
not lay.get("actions") or len(lay.get("actions") or []) < 2
):
lay["actions"] = list(flay["actions"])
if flay.get("preset") == "screenshot_faithful":
lay["preset"] = "screenshot_faithful"
if flay.get("filter_style") and not lay.get("filter_style"):
lay["filter_style"] = flay["filter_style"]
return draft
def infer_type(values: list[Any]) -> tuple[str, list[str] | None, float]:
samples = [v for v in values if v is not None and str(v).strip() != ""]
if not samples:
return "string", None, 0.4
strs = [str(v).strip() for v in samples]
uniq = sorted(set(strs))
# 高基数字段不当 enum避免导入新值失败
if len(uniq) <= 8 and len(uniq) <= max(2, int(len(strs) * 0.25)) and len(strs) >= 4:
if all(len(u) <= 32 for u in uniq):
return "enum", uniq[:50], 0.85
int_ok = True
dec_ok = True
for s in strs:
try:
if "." in s or "e" in s.lower():
float(s)
int_ok = False
else:
int(s)
except ValueError:
int_ok = False
try:
float(s)
except ValueError:
dec_ok = False
break
if int_ok:
return "int", None, 0.9
if dec_ok:
return "decimal", None, 0.88
if all(re.match(r"^\d{4}-\d{2}-\d{2}", s) for s in strs):
return "datetime", None, 0.8
avg_len = sum(len(s) for s in strs) / len(strs)
if avg_len > 80:
return "text", None, 0.7
return "string", None, 0.75
def parse_excel(content: bytes) -> dict[str, Any]:
wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
# 默认首表(不按行业表名挑选)
ws = wb[wb.sheetnames[0]] if wb.sheetnames else wb.active
rows = list(ws.iter_rows(values_only=True))
if not rows:
return {"sheets": []}
headers = [str(h).strip() if h is not None else f"col_{i}" for i, h in enumerate(rows[0])]
data_rows = rows[1:2001] # 最多采 2000 行做类型推断 / 蓝图
cols: dict[str, list[Any]] = {h: [] for h in headers}
sample_rows: list[list[Any]] = []
for r in data_rows:
if r is None or all(c is None or str(c).strip() == "" for c in r):
continue
vals = []
for i, h in enumerate(headers):
v = r[i] if i < len(r) else None
cols[h].append(v)
vals.append(v)
sample_rows.append(vals)
inferred = {}
for h in headers:
t, enum_vals, _ = infer_type(cols[h])
inferred[h] = {"type": t, "enum_values": enum_vals}
return {
"sheets": [
{
"name": ws.title,
"headers": headers,
"sample_rows": sample_rows[:5],
"row_dicts": [
{headers[i]: (r[i] if i < len(r) else None) for i in range(len(headers))}
for r in sample_rows[:500]
],
"inferred": inferred,
"columns": cols,
"row_count": len(sample_rows),
}
]
}
def _chart_rows_from_json(data: dict[str, Any]) -> list[dict[str, Any]]:
"""展开并行数组图表 JSONmcljChartData / cljdChartData→ 行字典。不写死行业文案。"""
mclj = data.get("mcljChartData") or {}
cljd = data.get("cljdChartData") or {}
dkilos = list(mclj.get("dkilo") or [])
cjls = list(mclj.get("cjl") or [])
sjcjls = list(mclj.get("sjcjl") or [])
workinfos = list(cljd.get("workinfo_kilo") or [])
befores = list(cljd.get("beforeDay") or cljd.get("before_day") or [])
nexts = list(cljd.get("nextDay") or cljd.get("next_day") or [])
freqs = list(cljd.get("frequency") or [])
# 颜色 → 分组N按出现顺序不臆造路基/桥梁等业务词
color_to_group: dict[str, str] = {}
rows: list[dict[str, Any]] = []
for i, dkilo in enumerate(dkilos):
parts = str(dkilo).split("_")
point_code = parts[0] if parts else str(dkilo)
mileage_m = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else None
chainage = ""
if mileage_m is not None:
km, rem = divmod(mileage_m, 1000)
chainage = f"DK{km}+{rem:03d}"
cjl = cjls[i] if i < len(cjls) else {}
if not isinstance(cjl, dict):
cjl = {"value": cjl, "color": ""}
try:
cjl_value = float(str(cjl.get("value") or 0).replace(",", ""))
except ValueError:
cjl_value = 0.0
color = str(cjl.get("color") or "").strip().lower()
try:
curve = abs(float(sjcjls[i])) if i < len(sjcjls) else 0.0
except (TypeError, ValueError):
curve = 0.0
design = 3.0 if curve >= 10 else (curve or 3.0)
cum_abs = abs(cjl_value)
exceed = round(max(0.0, cum_abs - design), 2)
if color and color not in color_to_group:
color_to_group[color] = f"分组{len(color_to_group) + 1}"
section = color_to_group.get(color) or f"分组{(i % 4) + 1}"
before = float(befores[i]) if i < len(befores) else 0.0
nxt = float(nexts[i]) if i < len(nexts) else 0.0
freq = float(freqs[i]) if i < len(freqs) else 0.0
rows.append(
{
"dkilo": str(dkilo),
"chainage": chainage or str(dkilo),
"point_code": point_code,
"section_type": section,
"worksite": f"分组{i // 40 + 1}",
"value": cjl_value,
"color": color,
"cjl_value": cjl_value,
"cjl_color": color,
"design_value": design,
"cum_value": cjl_value,
"pred_value": round(cjl_value * 0.9, 2),
"exceed_mm": exceed,
"exceed_days": int(round(exceed)) if exceed > 0 else 0,
"supervise_days": int(round(before)) if before > 1 else 28,
"overdue_days": max(0, int(round(nxt / 30)) if nxt > 50 else 0),
"before_day": before,
"next_day": nxt,
"frequency": freq,
"workinfo_kilo": workinfos[i] if i < len(workinfos) else "",
"status": "异常" if exceed > 0 else "正常",
}
)
return rows
def _flatten_json_object(obj: dict[str, Any], prefix: str = "") -> dict[str, Any]:
out: dict[str, Any] = {}
for k, v in obj.items():
key = slugify(f"{prefix}_{k}" if prefix else str(k), fallback="field")
if isinstance(v, dict) and "value" in v:
out[key] = v.get("value")
if "color" in v:
out[f"{key}_color"] = v.get("color")
elif isinstance(v, dict):
out.update(_flatten_json_object(v, key))
elif isinstance(v, list):
out[key] = json.dumps(v, ensure_ascii=False)
else:
out[key] = v
return out
def _rows_to_sheet(name: str, row_dicts: list[dict[str, Any]], *, link_field: str | None = None, parent: str | None = None) -> dict[str, Any]:
if not row_dicts:
return {"name": name, "headers": [], "sample_rows": [], "inferred": {}, "columns": {}, "row_count": 0}
headers = list(row_dicts[0].keys())
for r in row_dicts[1:]:
for k in r:
if k not in headers:
headers.append(k)
# 用全量做类型推断(上限 2000
use = row_dicts[:2000]
cols: dict[str, list[Any]] = {h: [r.get(h) for r in use] for h in headers}
inferred = {}
for h in headers:
t, enum_vals, _ = infer_type(cols[h])
inferred[h] = {"type": t, "enum_values": enum_vals}
sheet: dict[str, Any] = {
"name": name,
"headers": headers,
"sample_rows": [[r.get(h) for h in headers] for r in use[:5]],
"row_dicts": use[:500],
"inferred": inferred,
"columns": cols,
"source": "json",
"row_count": len(row_dicts),
}
if link_field:
sheet["link_field"] = link_field
if parent:
sheet["parent_entity"] = parent
return sheet
def parse_json_table(content: bytes) -> dict[str, Any]:
"""JSON → 一张或多张关联表sheets按结构拆实体。"""
text = content.decode("utf-8-sig")
data = json.loads(text)
sheets: list[dict[str, Any]] = []
if isinstance(data, dict) and ("mcljChartData" in data or "cljdChartData" in data):
points = _chart_rows_from_json(data)
primary = "chart_points"
sheets.append(_rows_to_sheet(primary, points))
# 并行状态数组拆成关联表(按 dkilo 关联)
cljd = data.get("cljdChartData") or {}
mclj = data.get("mcljChartData") or {}
dkilos = list(mclj.get("dkilo") or [])
workinfos = list(cljd.get("workinfo_kilo") or [])
befores = list(cljd.get("beforeDay") or cljd.get("before_day") or [])
nexts = list(cljd.get("nextDay") or cljd.get("next_day") or [])
freqs = list(cljd.get("frequency") or [])
superv = []
for i, dk in enumerate(dkilos):
superv.append(
{
"dkilo": str(dk),
"workinfo_kilo": workinfos[i] if i < len(workinfos) else "",
"before_day": float(befores[i]) if i < len(befores) else 0,
"next_day": float(nexts[i]) if i < len(nexts) else 0,
"frequency": float(freqs[i]) if i < len(freqs) else 0,
"supervise_days": int(round(float(befores[i]))) if i < len(befores) and float(befores[i]) > 1 else 28,
}
)
if superv:
sheets.append(
_rows_to_sheet(
"status_series",
superv,
link_field="dkilo",
parent=primary,
)
)
exceed = [
{
"dkilo": r["dkilo"],
"worksite": r.get("worksite"),
"point_code": r.get("point_code"),
"design_value": r.get("design_value"),
"cum_value": r.get("cum_value"),
"exceed_mm": r.get("exceed_mm"),
"exceed_days": r.get("exceed_days"),
"status": r.get("status"),
}
for r in points
if float(r.get("exceed_mm") or 0) > 0
]
if exceed:
sheets.append(
_rows_to_sheet(
"alert_points",
exceed,
link_field="dkilo",
parent=primary,
)
)
return {"sheets": sheets, "relations": [
{"from": "status_series", "to": primary, "on": "dkilo"},
{"from": "alert_points", "to": primary, "on": "dkilo"},
]}
if isinstance(data, list) and data and all(isinstance(x, dict) for x in data):
return {"sheets": [_rows_to_sheet("records", [_flatten_json_object(x) for x in data])]}
if isinstance(data, dict):
# 多个 list[object] → 多实体;尝试 *_id 关联
list_keys = [
k for k, v in data.items()
if isinstance(v, list) and v and isinstance(v[0], dict)
]
if len(list_keys) >= 1:
for k in list_keys:
rows = [_flatten_json_object(x) for x in data[k]]
sheets.append(_rows_to_sheet(slugify(k, fallback="records"), rows))
relations = []
names = [s["name"] for s in sheets]
for s in sheets:
for h in s.get("headers") or []:
if h.endswith("_id") or h.endswith("id") and h != "id":
base = h[:-3] if h.endswith("_id") else h
# 匹配其他实体名
for other in names:
if other != s["name"] and (base in other or other.startswith(base)):
relations.append({"from": s["name"], "to": other, "on": h})
s["link_field"] = h
s["parent_entity"] = other
# 同名字段关联
for other in sheets:
if other["name"] == s["name"]:
continue
common = set(s.get("headers") or []) & set(other.get("headers") or [])
common -= {"id", "created_at", "updated_at", "tenant_id"}
if len(common) == 1:
on = next(iter(common))
relations.append({"from": s["name"], "to": other["name"], "on": on})
s.setdefault("link_field", on)
s.setdefault("parent_entity", other["name"])
return {"sheets": sheets, "relations": relations}
for key in ("items", "rows", "data", "records", "list"):
if isinstance(data.get(key), list) and data[key] and isinstance(data[key][0], dict):
return {
"sheets": [
_rows_to_sheet(
slugify(key, fallback="records"),
[_flatten_json_object(x) for x in data[key]],
)
]
}
return {"sheets": [_rows_to_sheet("records", [_flatten_json_object(data)])]}
return {"sheets": []}
def parse_tabular_upload(filename: str, content: bytes) -> dict[str, Any]:
name = (filename or "").lower()
# 首页抓包key/value菜单 HTML + 图表 JSON + 统计 JSON …)
if name.endswith(".config") or name in {"url.config", "url_config", "capture.config"}:
from url_capture import parse_url_config
cap = parse_url_config(content)
warnings = list(cap.get("warnings") or [])
chart = cap.get("chart_json")
if not chart:
return {
"sheets": [],
"capture": cap,
"warnings": warnings + ["url.config 中未找到图表 JSONmcljChartData/cljdChartData"],
}
raw = json.dumps(chart, ensure_ascii=False).encode("utf-8")
meta = parse_json_table(raw)
meta["capture"] = cap
meta["warnings"] = warnings
meta.setdefault("ui_hints", {})
if cap.get("nav_items"):
meta["ui_hints"]["nav_items"] = cap["nav_items"]
stats = cap.get("stats_json") or {}
if isinstance(stats, dict) and stats:
meta["ui_hints"]["stats_raw"] = stats
if cap.get("exceed_json") is None and any(
p.get("empty") and "gonghou" in (p.get("action") or "").lower()
for p in (cap.get("pairs") or [])
):
warnings.append(
"hightchartGonghouCjlCX 返回为空:超限表由主图表数据按 exceed 条件推导(非原接口)"
)
meta["warnings"] = warnings
return meta
if name.endswith(".json"):
return parse_json_table(content)
if name.endswith(".csv"):
# 简易 CSV首行表头
text = content.decode("utf-8-sig")
import csv as _csv
reader = _csv.DictReader(io.StringIO(text))
rows = list(reader)[:5000]
if not rows:
return {"sheets": []}
headers = list(rows[0].keys())
use = rows[:2000]
cols: dict[str, list[Any]] = {h: [r.get(h) for r in use] for h in headers}
inferred = {h: {"type": infer_type(cols[h])[0], "enum_values": infer_type(cols[h])[1]} for h in headers}
return {
"sheets": [
{
"name": "csv_import",
"headers": headers,
"sample_rows": [[r.get(h) for h in headers] for r in use[:5]],
"inferred": inferred,
"columns": cols,
"source": "csv",
"row_count": len(rows),
}
]
}
return parse_excel(content)
def _fields_from_sheet(sheet: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str], list[str], list[str], list[float]]:
"""从一张 sheet 推断字段定义。"""
headers = sheet.get("headers") or []
inferred = sheet.get("inferred") or {}
cols = sheet.get("columns") or {}
fields: list[dict[str, Any]] = [
{
"name": "id",
"label": "ID",
"type": "bigint",
"nullable": False,
"ui": {"widget": "hidden", "listable": False},
}
]
filters: list[str] = []
sorts: list[str] = []
list_cols: list[str] = []
confidences: list[float] = []
for i, h in enumerate(headers):
fname = to_field_name(h, i)
if fname == "id":
fname = f"col_{i}"
info = inferred.get(h, {"type": "string", "enum_values": None})
ftype = info["type"]
_, _, c = infer_type(cols.get(h, []))
confidences.append(c)
field: dict[str, Any] = {
"name": fname,
"label": str(h)[:64],
"type": ftype,
"nullable": True,
"max_length": 128 if ftype == "string" else None,
"ui": {
"widget": "select" if ftype == "enum" else "input",
"listable": True,
"filterable": ftype in {"enum", "string"},
"sortable": ftype in {"int", "decimal", "datetime", "date", "string"},
"width": "md",
},
"from_excel": {
"column": h,
"sample_values": [str(x) for x in cols.get(h, [])[:3] if x is not None],
},
}
if ftype == "string":
uniq = sorted(
{str(v).strip() for v in cols.get(h, []) if v is not None and str(v).strip() != ""}
)
id_like = any(x in fname for x in ("code", "dkilo", "chainage", "kilo", "id", "name", "color"))
if not id_like and 1 < len(uniq) <= 8 and len(uniq) <= max(2, int(len(cols.get(h, [])) * 0.25)):
ftype = "enum"
field["type"] = "enum"
field["enum_values"] = uniq
field["ui"]["widget"] = "select"
field["ui"]["filterable"] = True
if ftype == "enum" and info.get("enum_values"):
field["enum_values"] = info["enum_values"][:24]
field["nullable"] = True
field["ui"]["widget"] = "select"
if ftype == "decimal":
field["precision"] = 12
field["scale"] = 2
field["ui"]["widget"] = "number"
if ftype == "int":
field["ui"]["widget"] = "number"
if ftype == "datetime":
field["ui"]["widget"] = "datetime"
if ftype == "date":
field["ui"]["widget"] = "datepicker"
field = {k: v for k, v in field.items() if v is not None}
fields.append(field)
list_cols.append(fname)
if field["ui"].get("filterable"):
filters.append(fname)
if field["ui"].get("sortable"):
sorts.append(fname)
return fields, filters, sorts, list_cols, confidences
def build_blueprint(
prompt: str,
excel_meta: dict[str, Any] | None,
storage_mode: str,
image_count: int,
html_count: int = 0,
) -> tuple[dict[str, Any], list[str], float]:
warnings: list[str] = []
confidences: list[float] = []
app_name = infer_app_name(prompt or "")
slug = slugify(app_name, fallback="app")
entities: list[dict[str, Any]] = []
resources: list[dict[str, Any]] = []
pages: list[dict[str, Any]] = []
primary_entity = "record"
primary_fields: list[dict[str, Any]] = []
primary_filters: list[str] = []
primary_list_cols: list[str] = []
label = "数据"
sheets = (excel_meta or {}).get("sheets") or []
relations = (excel_meta or {}).get("relations") or []
if sheets:
for si, sheet in enumerate(sheets):
ename = slugify(sheet.get("name") or f"record_{si}", fallback=f"record_{si}")
if ename in {"sheet1", "sheet", "sheet_1"}:
ename = "record" if si == 0 else f"record_{si}"
fields, filters, sorts, list_cols, confs = _fields_from_sheet(sheet)
confidences.extend(confs)
ent: dict[str, Any] = {
"name": ename,
"table": ename,
"label": str(sheet.get("name") or ename)[:32],
"primary_key": "id",
"fields": fields,
"indexes": [],
}
if sheet.get("parent_entity") and sheet.get("link_field"):
ent["relation"] = {
"parent": slugify(str(sheet["parent_entity"]), fallback="record"),
"on": to_field_name(str(sheet["link_field"]), 0),
}
entities.append(ent)
path = f"/{ename}" if ename.endswith("s") else f"/{ename}s"
resources.append(
{
"entity": ename,
"path": path,
"operations": ["list", "get", "create", "update", "delete", "import", "export"],
"list": {
"default_page_size": 50,
"max_page_size": 2000,
"allowed_filters": filters[:12],
"allowed_sorts": sorts[:12],
},
}
)
if si == 0:
primary_entity = ename
primary_fields = fields
primary_filters = filters
primary_list_cols = list_cols
label = ent["label"]
rc = sheet.get("row_count")
if rc:
warnings.append(f"主表 {ename} 识别到约 {rc} 行,发布后请导入完整 xlsx/json")
else:
pages.append(
{
"id": f"{ename}_list",
"title": f"{ent['label']}",
"route": f"/{ename}",
"type": "list",
"entity": ename,
"layout": {
"columns": list_cols[:8],
"filters": filters[:5],
"actions": ["create", "edit", "delete", "export", "import", "refresh"],
"action_labels": dict(DEFAULT_ACTION_LABELS),
},
}
)
if relations:
warnings.append(f"已按 JSON 字段关联生成 {len(relations)} 条表关系")
else:
warnings.append("未上传数据文件,已生成通用文本字段草案")
confidences.append(0.55)
primary_fields = [
{"name": "id", "label": "ID", "type": "bigint", "nullable": False, "ui": {"widget": "hidden", "listable": False}},
{"name": "title", "label": "标题", "type": "string", "nullable": False, "max_length": 128, "ui": {"widget": "input", "listable": True, "sortable": True}},
{"name": "content", "label": "内容", "type": "text", "nullable": True, "ui": {"widget": "textarea", "listable": True}},
{"name": "status", "label": "状态", "type": "enum", "nullable": True, "enum_values": ["草稿", "已发布"], "ui": {"widget": "select", "listable": True, "filterable": True}},
]
primary_filters = ["status"]
primary_list_cols = ["title", "content", "status"]
entities = [{"name": "record", "table": "record", "label": "数据", "primary_key": "id", "fields": primary_fields, "indexes": []}]
resources = [{
"entity": "record",
"path": "/records",
"operations": ["list", "get", "create", "update", "delete", "import", "export"],
"list": {"default_page_size": 50, "max_page_size": 2000, "allowed_filters": primary_filters, "allowed_sorts": ["title"]},
}]
if image_count > 0 and wants_screenshot_layout(prompt or "", image_count, html_count):
warnings.append(f"已收到 {image_count} 张截图:展示页将按截图分区还原")
confidences.append(0.75)
elif image_count > 0:
warnings.append(f"已收到 {image_count} 张截图(用户要求不按截图布局)")
confidences.append(0.7)
if html_count > 0:
warnings.append(f"已收到 {html_count} 个页面 HTML/MHTML文案与控件优先按页面源码还原")
confidences.append(0.8)
ui_hints = parse_ui_hints(prompt or "", image_count, html_count)
has_dashboard = (
wants_dashboard(prompt or "", image_count, html_count)
or image_count > 0
or html_count > 0
or bool(ui_hints.get("ui_preset"))
)
confidence = sum(confidences) / len(confidences) if confidences else 0.6
if prompt and excel_meta:
confidence = min(0.95, confidence + 0.05)
if ui_hints.get("app_name"):
app_name = ui_hints["app_name"]
if ui_hints.get("slug"):
slug = slugify(str(ui_hints["slug"]), fallback=slug)
item_label = infer_item_label(prompt or "", label)
list_title = ui_hints.get("list_title") or f"{item_label}列表"
create_title = ui_hints.get("create_title") or f"新增{item_label}"
dash_title = ui_hints.get("dash_title") or f"{item_label}看板"
filter_style = ui_hints.get("filter_style") or "default"
# 有截图或用户要求还原 → 通用截图忠实(不绑定行业默认文案)
faithful = (
ui_hints.get("ui_preset") == "screenshot_faithful"
or wants_screenshot_layout(prompt or "", image_count, html_count)
)
layout_preset = "screenshot_faithful" if faithful else "default"
if faithful:
ui_hints.setdefault("ui", {})
filter_style = ui_hints.get("filter_style") or filter_style
if ui_hints.get("app_name"):
app_name = ui_hints["app_name"]
if ui_hints.get("list_title"):
list_title = ui_hints["list_title"]
if ui_hints.get("create_title"):
create_title = ui_hints["create_title"]
if ui_hints.get("dash_title"):
dash_title = ui_hints["dash_title"]
elif dash_title.endswith("概览"):
dash_title = dash_title.replace("概览", "看板")
# 从数据字段补齐筛选(不写死行业词:有 enum 才出单选,有 worksite 类字段才出下拉)
ui_block = ui_hints.setdefault("ui", {})
field_by_name = {f["name"]: f for f in primary_fields}
if not ui_block.get("section_options") and not ui_block.get("filter_radios"):
for cand in ("section_type", "category", "type", "kind", "status"):
f = field_by_name.get(cand)
ev = (f or {}).get("enum_values") or []
if len(ev) >= 2:
ui_block["section_options"] = list(ev)[:8]
ui_block["filter_radios"] = list(ev)[:8]
ui_block["filter_style"] = "section_radios"
ui_block["radio_field"] = cand
filter_style = "section_radios"
break
if not ui_block.get("select_field") and not ui_block.get("filter_select_field"):
for cand in ("worksite", "site", "store", "warehouse", "dept", "org"):
if cand in field_by_name:
ui_block["select_field"] = cand
break
if ui_hints.get("ui", {}).get("filter_hint"):
pass
elif "点击" in (prompt or "") and "查看" in (prompt or ""):
m = re.search(r"(点击[^。\n]{2,40})", prompt or "")
if m:
ui_block.setdefault("filter_hint", m.group(1).strip())
list_actions = ui_hints.get("actions") or ["create", "edit", "delete", "export", "import", "refresh"]
action_labels = dict(DEFAULT_ACTION_LABELS)
if ui_hints.get("action_labels"):
action_labels.update(ui_hints["action_labels"])
# 列表列:优先通用 list_cols若提示词/字段含常用业务列再穿插(不写死行业)
preferred_cols = [c for c in primary_list_cols if c][:8]
preferred_filters = primary_filters[:5]
if ui_hints.get("filter_style") == "section_radios" and "section_type" in primary_filters:
preferred_filters = [f for f in ("section_type", "worksite", "status") if f in primary_filters] or preferred_filters
main_pages = [
{
"id": f"{primary_entity}_list",
"title": list_title,
"route": f"/{primary_entity}",
"type": "list",
"entity": primary_entity,
"layout": {
"columns": preferred_cols,
"filters": preferred_filters,
"actions": list_actions,
"action_labels": action_labels,
"filter_style": filter_style,
},
},
{
"id": f"{primary_entity}_create",
"title": create_title,
"route": f"/{primary_entity}/create",
"type": "form_create",
"entity": primary_entity,
"layout": {
"form_fields": [f["name"] for f in primary_fields if f["name"] not in _SYSTEM_FIELDS],
"actions": ["create"],
"action_labels": {k: action_labels.get(k, DEFAULT_ACTION_LABELS.get(k, k)) for k in ("create", "save", "cancel")},
},
},
]
if has_dashboard:
dash_widgets = build_dashboard_widgets(
primary_fields, primary_filters, primary_list_cols, primary_entity, item_label, prompt or ""
)
if faithful:
# 有截图时去掉顶栏 KPI避免破坏截图主区结构
types = {w.get("type") for w in dash_widgets}
if ("line_chart" in types or "bar_chart" in types) and (
"status_strip" in types or "table" in types
):
dash_widgets = [w for w in dash_widgets if w.get("type") != "kpi"]
main_pages.append(
{
"id": f"{primary_entity}_dashboard",
"title": dash_title,
"route": f"/{primary_entity}/dashboard",
"type": "dashboard",
"entity": primary_entity,
"layout": {
"preset": layout_preset,
"widgets": dash_widgets,
},
}
)
pages = main_pages + pages
draft = {
"version": "1.0",
"meta": {
"name": app_name,
"slug": slug,
"description": (prompt or "")[:500],
"locale": "zh-CN",
"source": {"prompt": prompt or ""},
"confidence": round(confidence, 2),
"ui_preset": layout_preset if faithful else (ui_hints.get("ui_preset") or "default"),
"project_context": ui_hints.get("project_context") or "",
"platform_title": (ui_hints.get("ui") or {}).get("platform_title") or "",
"ui": ui_hints.get("ui") or {},
},
"storage": {"mode": storage_mode or "schema_per_app", "engine": "postgres"},
"entities": entities,
"apis": {"base_path": f"/api/v1/apps/{slug}", "resources": resources},
"pages": pages,
"security": {
"visibility": "private",
"roles": [
{
"name": "owner",
"permissions": [
"app.read", "app.write", "app.admin",
"row.create", "row.read", "row.update", "row.delete", "row.export", "row.import",
],
},
{"name": "viewer", "permissions": ["app.read", "row.read", "row.export"]},
],
"row_policies": [{"entity": e["name"], "rule": "tenant_isolated"} for e in entities],
},
"seed": {"import_excel": bool(excel_meta), "max_rows": 5000},
"relations": relations,
}
return draft, warnings, round(confidence, 2)
@app.get("/health")
def health():
return {"ok": True}
@app.get("/api/v1/demo/fixtures")
def demo_fixtures():
"""测试素材清单(控制台一键预填用)。"""
return fixtures_manifest()
@app.get("/api/v1/demo/prompt")
def demo_prompt():
try:
text = read_prompt_text()
except Exception as e: # noqa: BLE001
return {"ok": False, "prompt": "", "error": str(e)}
return {"ok": True, "prompt": text, "slug": fixtures_manifest().get("slug")}
@app.get("/api/v1/demo/file/{file_path:path}")
def demo_file(file_path: str):
return file_response(file_path)
@app.post("/api/v1/preview")
async def preview_create(payload: dict[str, Any]):
"""创建草稿预览(无需登录/发布),返回 preview_id 供 /#/preview/{id} 打开。"""
bp = payload.get("blueprint")
if not isinstance(bp, dict):
return {"ok": False, "error": "blueprint required"}
rows = payload.get("rows") if isinstance(payload.get("rows"), list) else []
resource = str(payload.get("resource") or "")
pid = create_preview(bp, rows=rows, resource=resource)
return {
"ok": True,
"preview_id": pid,
"path": f"/#/preview/{pid}",
"url_hint": f"WEB_BASE/#/preview/{pid}",
}
@app.get("/api/v1/preview/{preview_id}")
def preview_get(preview_id: str):
pack = get_preview(preview_id)
if not pack:
return {"ok": False, "error": "preview not found or expired"}
return {
"ok": True,
"id": pack.get("id"),
"blueprint": pack.get("blueprint"),
"rows": pack.get("rows") or [],
"resource": pack.get("resource") or "records",
}
@app.get("/api/v1/llm/providers")
def llm_providers():
cfg = load_ai_config()
default = (
(os.getenv("LLM_PROVIDER") or "").strip()
or str(cfg.get("default_provider") or "deepseek")
)
return {"providers": list_providers(), "default": default}
@app.post("/api/v1/apps/generate")
@app.post("/api/v1/apps:generate") # 兼容旧路径,勿再新增第三套
async def generate(
prompt: str = Form(""),
storage_mode: str = Form("schema_per_app"),
llm_provider: str = Form(""),
llm_model: str = Form(""),
excel: UploadFile | None = File(None),
data_file: UploadFile | None = File(None), # 兼容json/csv/xlsx
images: list[UploadFile] | None = File(None),
layout_files: list[UploadFile] | None = File(None), # HTML/MHTMLCtrl+S 另存为)
):
with GenerateTrace() as trace:
excel_meta = None
upload = data_file if (data_file is not None and data_file.filename) else excel
if upload is not None and upload.filename:
content = await upload.read()
if content:
try:
excel_meta = parse_tabular_upload(upload.filename, content)
sheets = (excel_meta or {}).get("sheets") or []
trace.stage(
"parse_data",
upload.filename,
sheets=len(sheets),
rows=(sheets[0].get("row_count") if sheets else None),
)
except Exception as e: # noqa: BLE001
trace.error(f"数据文件解析失败: {e}")
return {
"draft": None,
"warnings": [f"数据文件解析失败: {e}"],
"confidence": 0.0,
"require_confirm": True,
"generate_log": trace.as_payload(),
}
image_blobs: list[tuple[str, bytes, str]] = []
for img in images or []:
if not img.filename:
continue
raw = await img.read()
if raw:
ctype = img.content_type or "image/png"
image_blobs.append((img.filename, raw, ctype))
layout_parsed_list: list[dict] = []
layout_texts: list[str] = []
layout_names: list[str] = []
for lf in layout_files or []:
if not lf.filename:
continue
raw = await lf.read()
if not raw:
continue
layout_names.append(lf.filename)
parsed = parse_layout_file(lf.filename, raw)
layout_parsed_list.append(parsed)
layout_texts.append(layout_summary_text(parsed))
image_count = len(image_blobs)
html_count = len(layout_names)
model_arg = llm_model.strip() or None
user_prompt = prompt or ""
trace.stage(
"inputs",
prompt_chars=len(user_prompt),
images=image_count,
layouts=html_count,
llm_provider=llm_provider,
llm_model=model_arg or "",
image_names=[n for n, _, _ in image_blobs],
layout_names=layout_names,
data_file=(upload.filename if upload and upload.filename else ""),
)
# HTML 按钮行并入用户需求,便于 parse_action_bar
html_btn_extra = ""
for parsed in layout_parsed_list:
if parsed.get("ok") and parsed.get("buttons"):
html_btn_extra += "\n操作:" + "".join(parsed["buttons"][:20])
prompt_for_hints = user_prompt + html_btn_extra
trace.stage("vision_extract", "开始理解截图")
vision_text, vision_notes = understand_images(user_prompt, image_blobs, llm_provider, None)
trace.stage(
"vision_extract",
"完成",
chars=len(vision_text or ""),
has_vision=bool(vision_text),
notes=len(vision_notes or []),
)
html_layout_text = "\n\n".join(layout_texts)
effective_prompt = compose_effective_prompt(
prompt_for_hints,
image_count,
vision_text,
html_layout_text,
html_count,
)
faithful = wants_screenshot_layout(user_prompt, image_count, html_count)
trace.stage(
"compose_prompt",
chars=len(effective_prompt or ""),
screenshot_faithful=faithful,
)
trace.stage("build_blueprint", "启发式草案")
draft, warnings, confidence = build_blueprint(
effective_prompt, excel_meta, storage_mode, image_count, html_count
)
# url.config 抓包:导航页签 / 空接口告警
if excel_meta and isinstance(excel_meta, dict):
for w in excel_meta.get("warnings") or []:
warnings.append(str(w))
cap_ui = excel_meta.get("ui_hints") or {}
if cap_ui.get("nav_items"):
meta = draft.setdefault("meta", {})
ui = meta.setdefault("ui", {})
ui.setdefault("nav_items", cap_ui["nav_items"])
cap = excel_meta.get("capture") or {}
if cap.get("pairs"):
warnings.append(
"已解析 url.config 抓包:"
+ "".join(
f"{p.get('action') or '?'}({'' if p.get('empty') else p.get('kind')})"
for p in cap["pairs"][:6]
)
)
# 用 HTML 再补一轮 ui_hints名称/页签),写回 meta
for parsed in layout_parsed_list:
if not parsed.get("ok"):
continue
hints = parse_ui_hints(prompt_for_hints, image_count, html_count)
merge_layout_into_ui_hints(hints, parsed)
meta = draft.setdefault("meta", {})
if hints.get("app_name") and (
not meta.get("name") or "数据管理" in str(meta.get("name"))
):
meta["name"] = hints["app_name"]
if hints.get("ui"):
meta["ui"] = {**(meta.get("ui") or {}), **hints["ui"]}
if hints["ui"].get("platform_title"):
meta["platform_title"] = hints["ui"]["platform_title"]
if hints.get("_html_buttons_line"):
ab = parse_action_bar(hints["_html_buttons_line"])
if ab:
for p in draft.get("pages") or []:
if isinstance(p, dict) and p.get("type") == "list":
lay = p.setdefault("layout", {})
lay["actions"] = ab["actions"]
labels = dict(lay.get("action_labels") or {})
labels.update(ab["action_labels"])
lay["action_labels"] = labels
# 页标题 + widget 标题(来自 HTML label
for p in draft.get("pages") or []:
if not isinstance(p, dict):
continue
if p.get("type") == "list" and hints.get("list_title"):
p["title"] = hints["list_title"]
if p.get("type") == "form_create" and hints.get("create_title"):
p["title"] = hints["create_title"]
if p.get("type") == "dashboard":
if hints.get("dash_title"):
p["title"] = hints["dash_title"]
elif p.get("title") and "概览" in str(p.get("title")):
p["title"] = str(p["title"]).replace("概览", "看板")
lay = p.setdefault("layout", {})
for w in lay.get("widgets") or []:
if not isinstance(w, dict):
continue
if w.get("type") in {"line_chart", "bar_chart"} and hints.get("chart_title"):
w["title"] = hints["chart_title"]
if w.get("type") == "status_strip" and hints.get("strip_title"):
w["title"] = hints["strip_title"]
if w.get("type") == "table" and (
hints.get("table_title") or (hints.get("ui") or {}).get("table_title")
):
w["title"] = hints.get("table_title") or hints["ui"]["table_title"]
baseline = json.loads(json.dumps(draft)) # deep copy
warnings.extend(vision_notes)
for parsed in layout_parsed_list:
if parsed.get("ok"):
warnings.append(f"已解析页面布局: {parsed.get('filename')}")
else:
warnings.append(f"页面布局解析失败: {parsed.get('filename')} ({parsed.get('error')})")
trace.notes(warnings)
slug0 = (draft.get("meta") or {}).get("slug") or ""
trace.stage(
"build_blueprint",
"完成",
slug=slug0,
pages=len(draft.get("pages") or []),
confidence=confidence,
)
excel_summary = ""
if excel_meta and excel_meta.get("sheets"):
sh = excel_meta["sheets"][0]
excel_summary = f"sheet={sh.get('name')} headers={sh.get('headers')}"
if html_layout_text:
excel_summary = (excel_summary + " | " if excel_summary else "") + "html_layout=yes"
trace.stage("llm_enhance", "代码模型润色蓝图")
draft, llm_notes = enhance_blueprint_with_llm(
draft, effective_prompt, excel_summary, llm_provider, model_arg
)
warnings.extend(llm_notes)
trace.notes(llm_notes)
draft = harden_draft(draft, baseline)
draft.setdefault("meta", {}).setdefault("source", {})
draft["meta"]["source"]["prompt"] = user_prompt
draft["meta"]["source"]["llm_provider"] = llm_provider
if model_arg:
draft["meta"]["source"]["llm_model"] = model_arg
if upload is not None and upload.filename:
draft["meta"]["source"]["excel_ref"] = f"upload://{upload.filename}"
if excel_meta and excel_meta.get("sheets"):
draft["meta"]["source"]["data_format"] = excel_meta["sheets"][0].get("source") or "excel"
if excel_meta["sheets"][0].get("row_count"):
draft["meta"]["source"]["row_count"] = excel_meta["sheets"][0]["row_count"]
if image_count:
draft["meta"]["source"]["image_refs"] = [n for n, _, _ in image_blobs]
if vision_text:
draft["meta"]["source"]["vision_summary"] = vision_text[:1000]
if html_count:
draft["meta"]["source"]["layout_refs"] = layout_names
draft["meta"]["source"]["layout_summary"] = html_layout_text[:2000]
if image_count or html_count:
draft["meta"]["source"]["screenshot_faithful"] = faithful
if faithful:
draft["meta"]["ui_preset"] = "screenshot_faithful"
for p in draft.get("pages") or []:
if isinstance(p, dict) and p.get("type") == "dashboard":
p.setdefault("layout", {})["preset"] = "screenshot_faithful"
draft["meta"]["source"]["generate_run_id"] = trace.run_id
trace.stage("llm_enhance", "完成", slug=(draft.get("meta") or {}).get("slug") or "")
fidelity_report: dict = {"skipped": True, "reason": "not_applicable"}
if image_count and faithful:
# 视觉摘录已写入 effective_prompt此处预览/计划 ↔ 原图打分 → 代码模型改蓝图 → 循环至 ≥95%
trace.stage("fidelity_loop", "开始还原度迭代")
preview_rows: list[dict] = []
if excel_meta and isinstance(excel_meta, dict):
for sh in excel_meta.get("sheets") or []:
if isinstance(sh, dict) and sh.get("row_dicts"):
preview_rows = list(sh["row_dicts"])[:500]
break
draft, fidelity_report, fid_notes = await asyncio.to_thread(
run_fidelity_loop,
draft,
image_blobs,
vision_text,
llm_provider=llm_provider,
llm_model=model_arg,
vision_model=None, # 视觉通道只用 VISION_MODEL勿混入文本模型名
harden_fn=harden_draft,
baseline=baseline,
preview_rows=preview_rows,
)
warnings.extend(fid_notes)
draft = harden_draft(draft, baseline)
draft.setdefault("meta", {}).setdefault("source", {})
draft["meta"]["source"]["fidelity"] = {
"final_score": fidelity_report.get("final_score"),
"passed": fidelity_report.get("passed"),
"rounds": len(fidelity_report.get("rounds") or []),
"target": fidelity_report.get("target"),
}
if fidelity_report.get("passed"):
draft["meta"]["confidence"] = max(
float(draft["meta"].get("confidence") or confidence or 0),
0.95,
)
trace.stage(
"fidelity_loop",
"结束",
final_score=fidelity_report.get("final_score"),
passed=fidelity_report.get("passed"),
rounds=len(fidelity_report.get("rounds") or []),
target=fidelity_report.get("target"),
)
else:
trace.stage(
"fidelity_loop",
"跳过",
reason=("no_images" if not image_count else "not_screenshot_faithful"),
)
conf = float(draft.get("meta", {}).get("confidence") or confidence)
log_payload = trace.as_payload()
draft.setdefault("meta", {}).setdefault("source", {})
draft["meta"]["source"]["generate_log_file"] = log_payload.get("log_file") or ""
return {
"draft": draft,
"warnings": warnings,
"confidence": conf,
"require_confirm": True,
"llm_provider": llm_provider,
"llm_model": model_arg or "",
"fidelity": fidelity_report,
"generate_log": log_payload,
}