aps-agent/server/aps_domain/project_analyze.py

582 lines
26 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.

# ============================================================
# 项目深度分析(moduleId: domain-project-analyze, 可重生 ✅)
# 工程目录 Excel/SQL → 写入当前项目世界 → 明细表 + 齐备度 + 知识库补洞 plan
# ============================================================
from __future__ import annotations
import os
import re
from typing import Any
from server.aps_domain.readiness import check_readiness
World = dict[str, Any]
_TABLE_EXT_RE = re.compile(r"\.(xlsx|xlsm|csv|txt)$", re.I)
_INPUT_EXT_RE = re.compile(r"\.(xlsx|xlsm|csv|txt|sql)$", re.I)
def _project_ctx(session_id: str | None) -> tuple[dict | None, str]:
try:
from server.state.projects import get_project_store
snap = get_project_store().snapshot(include_messages=False)
except Exception:
return None, ""
sessions = {s["id"]: s for s in (snap.get("sessions") or []) if isinstance(s, dict)}
projects = {p["id"]: p for p in (snap.get("projects") or []) if isinstance(p, dict)}
sess = sessions.get(session_id or "") or {}
proj = projects.get(sess.get("projectId") or "") if sess.get("projectId") else None
work = (proj.get("workDir") or "").strip() if proj else ""
return proj, work
def _input_path_from_query(query: str) -> str:
"""从自然语言中提取当前机器上真实存在的文件/目录路径。"""
text = str(query or "").strip()
if not text:
return ""
candidates: list[str] = []
for match in re.finditer(r'["“”\']([^"“”\']+)["“”\']', text):
candidates.append(match.group(1))
candidates.extend(re.findall(
r"[A-Za-z]:[\\/][^\r\n\"'<>|]+?\.(?:xlsx|xlsm|csv|txt|sql)",
text,
flags=re.I,
))
# 没带引号时,也允许整句本身就是一个目录。
candidates.append(text)
for raw in candidates:
value = raw.strip().strip("`,。;;、")
if os.path.isfile(value) and _INPUT_EXT_RE.search(value):
return os.path.abspath(value)
if os.path.isdir(value):
return os.path.abspath(value)
return ""
def _direct_table_report(
world: World,
paths: list[str],
*,
project_name: str,
work_dir: str,
) -> dict[str, Any]:
"""直接路径旁路:复用 preview_file,但不依赖工作区里的 workDir。"""
from server.aps_domain.folder_pack import KIND_CN
from server.aps_domain.importers import preview_file
required = ("orders", "materials", "routing", "equipment")
files: list[dict[str, Any]] = []
batches: list[dict[str, Any]] = []
kind_counts: dict[str, int] = {}
for path in paths:
name = os.path.basename(path)
try:
with open(path, "rb") as source:
preview = preview_file(name, source.read(), world, soft=True)
file_batches = preview.get("batches") or []
ok_count = int(preview.get("totalOk") or 0)
error_count = int(preview.get("totalErrors") or 0)
primary_kind = next(
(str(batch.get("kind")) for batch in file_batches if batch.get("okCount")),
str((file_batches[0] if file_batches else {}).get("kind") or ""),
)
field_map = next(
(list(batch.get("fieldMap") or []) for batch in file_batches if batch.get("fieldMap")),
[],
)
errors: list[str] = []
warnings: list[str] = []
headers: list[str] = []
samples: list[str] = []
sheets: list[dict[str, Any]] = []
for batch in file_batches:
kind = str(batch.get("kind") or primary_kind or "materials")
count = int(batch.get("okCount") or 0)
if count:
kind_counts[kind] = kind_counts.get(kind, 0) + count
errors.extend(str(item) for item in (batch.get("errors") or [])[:2])
warnings.extend(str(item) for item in (batch.get("warnings") or [])[:2])
if not headers:
headers = [str(item) for item in (batch.get("headersRaw") or []) if item][:8]
for row in (batch.get("okRows") or [])[:1]:
if isinstance(row, dict):
samples.append(";".join(f"{k}={v}" for k, v in list(row.items())[:6]))
sheets.append({
"sheet": batch.get("sheet"), "kind": kind,
"kindCn": KIND_CN.get(kind, kind), "okCount": count,
"errorCount": int(batch.get("errorCount") or 0),
})
if count:
batches.append({
"kind": kind, "sheet": batch.get("sheet") or name,
"okRows": batch.get("okRows") or [], "okCount": count,
"errorCount": int(batch.get("errorCount") or 0),
"errors": (batch.get("errors") or [])[:8],
"warnings": (batch.get("warnings") or [])[:5],
"fieldMap": batch.get("fieldMap") or [], "sourceFile": name,
})
fmap_text = "、".join(
f"{item.get('source')}→{item.get('target')}" for item in field_map
)
issue = (errors or warnings or ([f"字段:{fmap_text}"] if fmap_text else []))
files.append({
"name": name, "size": os.path.getsize(path),
"status": "ok" if ok_count and not error_count else ("warn" if ok_count else "fail"),
"kind": primary_kind, "kindCn": KIND_CN.get(primary_kind, primary_kind or "未识别"),
"okCount": ok_count, "errorCount": error_count,
"errors": errors, "warnings": warnings, "headers": headers,
"samples": samples, "sheets": sheets, "fieldMap": field_map,
"fieldMapText": fmap_text, "issue": issue[0] if issue else "",
})
except Exception as exc: # noqa: BLE001
files.append({
"name": name, "size": os.path.getsize(path) if os.path.isfile(path) else 0,
"status": "fail", "kind": "", "kindCn": "读取失败",
"okCount": 0, "errorCount": 1, "errors": [str(exc)],
"warnings": [], "headers": [], "samples": [], "sheets": [],
"fieldMap": [], "fieldMapText": "", "issue": str(exc),
})
coverage = {kind: kind_counts.get(kind, 0) > 0 for kind in required}
coverage_detail = [{
"key": kind, "label": KIND_CN[kind], "ok": coverage[kind],
"count": kind_counts.get(kind, 0), "fieldMap": [],
"note": (f"读到 {kind_counts[kind]} 行" if coverage[kind] else "还没有有效数据"),
} for kind in required]
missing = [KIND_CN[kind] for kind in required if not coverage[kind]]
return {
"ok": True, "projectName": project_name, "workDir": work_dir,
"files": files, "coverage": coverage, "coverageDetail": coverage_detail,
"kindCounts": kind_counts, "missing": missing, "softMissing": [],
"canSchedule": not missing, "batches": batches,
"totalOk": sum(int(item.get("okCount") or 0) for item in files),
"totalErrors": sum(int(item.get("errorCount") or 0) for item in files),
}
def _fill_from_knowledge(world: World, product_codes: list[str]) -> list[dict[str, Any]]:
"""缺工艺时用行业模板/知识库自动补;返回动作清单。"""
actions: list[dict[str, Any]] = []
try:
from server.knowledge.routing_templates import recommend_templates, apply_template_to_product
except Exception:
return actions
routes = {r.get("productCode") for r in (world.get("flexRoutings") or [])}
mats = {m.get("code") for m in (world.get("flexMaterials") or [])}
for pc in product_codes:
if not pc:
continue
if pc not in mats:
world.setdefault("flexMaterials", []).append({
"id": len(world.get("flexMaterials") or []) + 1,
"code": pc, "name": pc, "type": "FINISHED_PRODUCT", "unit": "件",
"stock": 0, "inTransit": 0, "safetyStock": 0, "procurementLeadTime": 0,
})
actions.append({"type": "material_stub", "productCode": pc,
"detail": f"物料主数据缺失,已建产品档案占位 {pc}"})
if pc in routes:
continue
name = next((m.get("name") for m in (world.get("flexMaterials") or [])
if m.get("code") == pc), pc)
try:
recs = recommend_templates(f"{pc} {name}") or []
except Exception:
recs = []
if not recs:
actions.append({"type": "need_routing", "productCode": pc,
"detail": f"产品 {pc} 无工艺,知识库也无匹配模板,需客户提供工艺路线"})
continue
tpl = recs[0]
code = tpl.get("code") or tpl.get("templateCode")
try:
apply_template_to_product(world, code, pc)
actions.append({
"type": "template_applied", "productCode": pc,
"template": code, "templateName": tpl.get("name"),
"detail": f"产品 {pc} 缺工艺 → 已用知识库模板「{tpl.get('name') or code}」生成",
})
routes.add(pc)
except Exception as exc:
actions.append({"type": "template_failed", "productCode": pc,
"detail": f"模板 {code} 应用失败:{exc}"})
return actions
def analyze_project_deep(
world: World,
session_id: str | None = None,
*,
apply_sql: bool = True,
query: str = "",
next_id=None,
) -> dict[str, Any]:
"""深度分析当前项目:目录包(SQL/Excel)+ 世界主数据 + 知识库补洞。"""
try:
from server.agent_core.progress import emit_thinking
except Exception:
def emit_thinking(*_a, **_k): # type: ignore
return None
proj, work = _project_ctx(session_id)
explicit_input = _input_path_from_query(query)
if explicit_input:
work = explicit_input if os.path.isdir(explicit_input) else os.path.dirname(explicit_input)
if proj is None:
proj = {
"name": os.path.basename(work.rstrip("\\/")) or "当前项目",
"workDir": work,
}
sources: list[str] = []
source_paths: list[str] = []
sql_preview: dict[str, Any] | None = None
applied: dict[str, int] = {}
kb_actions: list[dict[str, Any]] = []
def apply_folder_batches(report: dict[str, Any] | None) -> None:
batches = (report or {}).get("batches") or []
if not batches or next_id is None:
return
from server.aps_domain.importers import apply_import_commit
counts = apply_import_commit(world, next_id, batches)
for key, value in counts.items():
applied[key] = applied.get(key, 0) + int(value or 0)
emit_thinking("定位工程目录", (proj or {}).get("name") or "当前项目", pct=2)
if work and os.path.isdir(work):
names = ([os.path.basename(explicit_input)] if os.path.isfile(explicit_input)
else sorted(os.listdir(work)))
sqls = [n for n in names if n.lower().endswith(".sql") and not n.startswith("~$")]
xls = [n for n in names if _TABLE_EXT_RE.search(n) and not n.startswith("~$")]
source_paths = [
os.path.join(work, name) for name in (*sqls, *xls)
if os.path.isfile(os.path.join(work, name))
]
folder = None
emit_thinking(
"清点目录文件",
f"SQL {len(sqls)} · Excel/CSV {len(xls)}",
pct=5,
)
if sqls:
from server.importers.sql_pack import preview_sql_file, apply_sql_pack_to_world
# 优先最大的 sql(完整库)
sqls_sorted = sorted(sqls, key=lambda n: os.path.getsize(os.path.join(work, n)), reverse=True)
path = os.path.join(work, sqls_sorted[0])
sources.append(f"SQL:{sqls_sorted[0]}")
try:
sql_preview = preview_sql_file(path)
if apply_sql and sql_preview.get("flex"):
emit_thinking("写入项目世界", "订单 / 物料 / 工艺 / 设备", pct=93)
# SQL 包是工程目录真相源,始终整包覆盖,避免旧错误工艺残留
applied = apply_sql_pack_to_world(
world, sql_preview["flex"], replace=True)
emit_thinking(
"入库完成",
"、".join(f"{k}{v}" for k, v in applied.items()) or "无新增",
status="done", pct=95,
)
except Exception as exc:
sql_preview = {"error": str(exc), "filename": sqls_sorted[0]}
emit_thinking("SQL 解析失败", str(exc), status="warn")
elif xls:
# 无 SQL 时:识别 MOM 主数据收集表并写入世界(否则深度分析永远是 0)
mom_applied = False
try:
from server.importers.mom_pack import is_mom_workbook, import_mom_excel
mom_files = [
n for n in xls
if is_mom_workbook(os.path.join(work, n))
]
# 文件名含 MOM/主数据 优先;否则对全部 xlsx 试探
candidates = mom_files or xls
for n in candidates:
path = os.path.join(work, n)
if not is_mom_workbook(path):
continue
sources.append(f"MOM:{n}")
emit_thinking("识别 MOM 主数据表", n, pct=90)
result = import_mom_excel(world, path, replace=True)
applied = result.get("applied") or {}
mom_applied = True
emit_thinking(
"MOM 入库完成",
"、".join(f"{k}{v}" for k, v in applied.items()) or "无新增",
status="done", pct=95,
)
stats = result.get("stats") or {}
if stats.get("note"):
sources.append(str(stats["note"]))
break
except Exception as exc:
emit_thinking("MOM Excel 解析失败", str(exc), status="warn")
if not mom_applied:
sources.append(f"Excel×{len(xls)}")
try:
emit_thinking("解析并写入 Excel/CSV", f"{len(xls)} 个表格", pct=96)
if explicit_input:
folder = _direct_table_report(
world,
[os.path.join(work, name) for name in xls],
project_name=(proj or {}).get("name") or "当前项目",
work_dir=work,
)
else:
from server.aps_domain.folder_pack import analyze_work_dir
folder = analyze_work_dir(world, session_id)
apply_folder_batches(folder)
except Exception:
folder = None
else:
try:
if explicit_input:
folder = _direct_table_report(
world,
[os.path.join(work, name) for name in xls],
project_name=(proj or {}).get("name") or "当前项目",
work_dir=work,
)
else:
from server.aps_domain.folder_pack import analyze_work_dir
folder = analyze_work_dir(world, session_id)
except Exception:
folder = None
else:
folder = None
if sqls and xls:
sources.append(f"Excel×{len(xls)}")
try:
emit_thinking("核对 Excel/CSV", f"{len(xls)} 个表格", pct=96)
if explicit_input:
folder = _direct_table_report(
world,
[os.path.join(work, name) for name in xls],
project_name=(proj or {}).get("name") or "当前项目",
work_dir=work,
)
else:
from server.aps_domain.folder_pack import analyze_work_dir
folder = analyze_work_dir(world, session_id)
apply_folder_batches(folder)
except Exception:
folder = None
else:
folder = None
emit_thinking("未挂工程目录", "仅分析当前项目世界里的数据", status="warn", pct=10)
# 知识库补洞:缺工艺的产品
emit_thinking("检查齐备度与知识库", "缺工艺时自动匹配行业模板", pct=97)
active = [o for o in (world.get("flexOrders") or [])
if (o.get("status") or "") not in ("DONE", "CANCELLED")]
pcs = list({str(o.get("productCode") or "") for o in active})
kb_actions = _fill_from_knowledge(world, pcs)
if kb_actions:
emit_thinking(
"知识库补洞",
f"{len(kb_actions)} 项(模板/占位/待客户补)",
status="done", pct=98,
)
ready = check_readiness(world)
emit_thinking("整理分析报告", f"订单 {len(active)} 张", status="done", pct=99)
mats = world.get("flexMaterials") or []
routes = world.get("flexRoutings") or []
equip = [e for e in (world.get("flexEquipment") or []) if e.get("status") == "RUNNING"]
bom = world.get("flexBom") or []
order_rows = [{
"orderNo": o.get("orderNo"),
"productCode": o.get("productCode"),
"productName": o.get("productName") or next(
(m.get("name") for m in mats if m.get("code") == o.get("productCode")), ""),
"quantity": o.get("quantity"),
"dueDate": o.get("dueDate"),
"customerName": o.get("customerName") or "",
"ready": next((r["ready"] for r in ready["orders"] if r["orderNo"] == o.get("orderNo")), False),
} for o in active[:80]]
# 主数据抽样:订单涉及的产品优先
need_codes = {r["productCode"] for r in order_rows if r.get("productCode")}
mat_rows = []
for m in mats:
if need_codes and m.get("code") not in need_codes and len(mat_rows) >= 40:
continue
if m.get("code") in need_codes or len(mat_rows) < 40:
mat_rows.append({
"code": m.get("code"), "name": m.get("name"),
"type": m.get("type"), "unit": m.get("unit"), "stock": m.get("stock"),
})
if len(mat_rows) >= 60:
break
route_rows = []
for r in routes:
if need_codes and r.get("productCode") not in need_codes and len(route_rows) >= 40:
continue
route_rows.append({
"productCode": r.get("productCode"),
"seq": r.get("seq"),
"operationCode": r.get("operationCode"),
"operationName": r.get("operationName") or r.get("operationCode"),
"stdMin": r.get("stdTimePerUnit"),
})
if len(route_rows) >= 80:
break
equip_rows = [{
"code": e.get("code"), "name": e.get("name"),
"capabilities": "、".join((e.get("capabilities") or [])[:6]),
} for e in equip[:40]]
# 缺口 plan:先库后客户(同类缺口合并,避免刷屏)
plan: list[str] = []
need_rt = [a for a in kb_actions if a.get("type") == "need_routing"]
applied_tpl = [a for a in kb_actions if a.get("type") == "template_applied"]
other_kb = [a for a in kb_actions if a.get("type") not in ("need_routing", "template_applied")]
if applied_tpl:
plan.append(f"✓ 已用知识库模板补工艺 {len(applied_tpl)} 个产品")
for a in applied_tpl[:5]:
plan.append(f" · {a.get('productCode')} ← {a.get('templateName') or a.get('template')}")
if len(applied_tpl) > 5:
plan.append(f" · …另有 {len(applied_tpl) - 5} 个")
if need_rt:
samples = "、".join(str(a.get("productCode") or "") for a in need_rt[:8])
more = f" 等共 {len(need_rt)} 个" if len(need_rt) > 8 else f"({len(need_rt)} 个)"
plan.append(
f"△ SQL/知识库仍未挂上工艺的产品:{samples}{more}。"
f"若现场库本有工艺,请重新「分析项目」(工艺按订单 craftl 关联,不是中间件料号)"
)
for a in other_kb[:6]:
plan.append(f"· {a['detail']}")
s = ready["summary"]
if s.get("blocked"):
plan.append(f"△ 仍有 {s['blocked']} 张单齐备度未过,见订单明细「是否可排」列")
if not active:
plan.append("△ 没有订单:请在工程目录放订单表/SQL,或说「新建订单」")
if not equip:
plan.append("△ 没有可用设备:请提供设备台账,或从 SQL 的 md_equipment 导入")
if not plan:
plan.append("✓ 主数据与知识库已齐,可直接说「给我排产」")
proj_name = (proj or {}).get("name") or "当前项目"
md_lines = [
f"**项目深度分析 · {proj_name}**",
"",
f"数据来源:{'、'.join(sources) if sources else '当前项目世界(未挂工程目录文件)'}",
]
# 绑定当前登录用户
try:
from server.knowledge.ingest import bind_world_to_identity, ingest_project_analyze_report
owner_meta = bind_world_to_identity(world, sources=sources)
md_lines.append(
f"归属:{owner_meta.get('ownerFullname') or owner_meta.get('ownerUsername')} "
f"(用户 {owner_meta.get('ownerUserId')} · 租户 {str(owner_meta.get('tenantUuid') or '')[:8]}…)"
)
except Exception:
owner_meta = {}
ingest_project_analyze_report = None # type: ignore
if applied:
md_lines.append(
"已入库:"
+ "、".join(f"{k} {v}" for k, v in applied.items())
)
if sql_preview and sql_preview.get("fieldMapText"):
md_lines.append(f"SQL 包识别:{sql_preview['fieldMapText']}")
md_lines += [
"",
f"订单 **{len(active)}** · 物料 **{len(mats)}** · 工艺步 **{len(routes)}** · "
f"设备 **{len(equip)}** · BOM **{len(bom)}**",
"",
"### 订单明细(本项目)",
"",
"| 订单号 | 产品 | 名称 | 数量 | 交期 | 客户 | 可排 |",
"| --- | --- | --- | ---: | --- | --- | --- |",
]
for r in order_rows[:30]:
md_lines.append(
f"| {r['orderNo']} | {r['productCode']} | {str(r.get('productName') or '')[:16]} "
f"| {r['quantity']} | {r['dueDate'] or '—'} | {str(r.get('customerName') or '')[:12]} "
f"| {'✓' if r['ready'] else '×'} |"
)
if len(order_rows) > 30:
md_lines.append(f"| … | 另有 {len(order_rows) - 30} 张 | | | | | |")
md_lines += [
"",
"### 主数据(产品/物料抽样)",
"",
"| 编码 | 名称 | 类型 | 单位 | 库存 |",
"| --- | --- | --- | --- | ---: |",
]
for m in mat_rows[:25]:
md_lines.append(
f"| {m['code']} | {str(m.get('name') or '')[:20]} | {m.get('type')} "
f"| {m.get('unit')} | {m.get('stock') or 0} |"
)
md_lines += ["", "### 补数 Plan(先知识库/数据库,再找客户)", ""]
for i, p in enumerate(plan[:12], 1):
md_lines.append(f"{i}. {p}")
result = {
"ok": True,
"projectName": proj_name,
"workDir": work,
"sources": sources,
"sourcePaths": source_paths,
"applied": applied,
"owner": {
"userId": owner_meta.get("ownerUserId"),
"username": owner_meta.get("ownerUsername"),
"fullname": owner_meta.get("ownerFullname"),
"tenantUuid": owner_meta.get("tenantUuid"),
} if owner_meta else None,
"sqlPreview": {
"filename": (sql_preview or {}).get("filename"),
"fieldMapText": (sql_preview or {}).get("fieldMapText"),
"tables": (sql_preview or {}).get("tables") or [],
"error": (sql_preview or {}).get("error"),
} if sql_preview else None,
"folder": folder,
"orders": order_rows,
"materials": mat_rows,
"routings": route_rows,
"equipment": equip_rows,
"kbActions": kb_actions,
"plan": plan,
"readiness": ready,
"summary": {
"orders": len(active), "materials": len(mats),
"routings": len(routes), "equipment": len(equip), "bom": len(bom),
"ready": s.get("ready", 0), "blocked": s.get("blocked", 0),
},
"canSchedule": s.get("ready", 0) > 0 and not ready.get("globalIssues"),
"markdown": "\n".join(md_lines),
}
# 分析结果写入当前租户知识库(平台知识模块,按登录租户隔离)
knowledge_ingest: dict[str, Any] = {}
try:
has_data = bool(
result["summary"]["materials"]
or result["summary"]["orders"]
or result["summary"]["equipment"]
or result["summary"]["bom"]
or result.get("sourcePaths")
)
if ingest_project_analyze_report is not None and has_data:
knowledge_ingest = ingest_project_analyze_report(result, world=world)
if knowledge_ingest.get("assetId"):
md_lines.append("")
md_lines.append(
f"已写入知识库:《{knowledge_ingest.get('title')}》"
f"({knowledge_ingest.get('chunkCount')} 段,{knowledge_ingest.get('version')})"
)
result["markdown"] = "\n".join(md_lines)
except Exception as exc:
knowledge_ingest = {"error": str(exc)}
result["knowledgeIngest"] = knowledge_ingest
return result