Files
BZJZ_Material/博志金钻项目基本情况_知识库/wiki/_system/build_company_kb.py

598 lines
20 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.
#!/usr/bin/env python3
"""Build a local multimodal KB from BZJZ company PPTX files.
The script reads PPTX files as zip/XML packages, extracts slide text and
embedded images, renders each slide through LibreOffice + pdftoppm, and writes
Markdown source notes plus image indexes.
"""
from __future__ import annotations
import datetime as _dt
import hashlib
import os
import re
import shutil
import subprocess
import sys
import textwrap
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from xml.etree import ElementTree as ET
from PIL import Image
ROOT = Path(__file__).resolve().parents[3]
SOURCE_DIR = ROOT / "博志金钻项目基本情况"
KB_DIR = ROOT / "博志金钻项目基本情况_知识库"
WIKI_DIR = KB_DIR / "wiki"
ASSET_DIR = KB_DIR / "assets"
TMP_DIR = KB_DIR / "_tmp_render"
INGEST_TIME = os.environ.get("KB_INGEST_TIME") or _dt.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
PPT_SLUGS = {
"2025_3_5_苏州博志金钻-MEMS传感器智能制造基地-项目计划书.pptx": (
"ppt01_mems_sensor_base_plan",
"MEMS传感器智能制造基地项目计划书",
),
"※2024_2_24-苏州博志金钻-V终-苹果验厂产品介绍.pptx": (
"ppt02_apple_audit_product_intro",
"苹果验厂产品介绍",
),
"※2025_4_12_苏州博志金钻科技有限责任公司介绍材料 .pptx": (
"ppt03_company_intro_20250412",
"苏州博志金钻科技有限责任公司介绍材料 2025-04-12",
),
"※2025_4_15_苏州博志金钻科技有限责任公司介绍tc.pptx": (
"ppt04_company_intro_tc_20250415",
"苏州博志金钻科技有限责任公司介绍 2025-04-15 tc",
),
}
NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"rel": "http://schemas.openxmlformats.org/package/2006/relationships",
}
SECTION_KEYWORDS = {
"目录",
"市场分析",
"投资规划",
"产品与技术方案",
"建设与运营方案",
"财务预测",
"About Us",
}
CORE_KEYWORDS = [
"About Us",
"公司",
"创新",
"技术",
"产品",
"产品管线",
"陶瓷载板",
"TEC",
"DPC",
"激光热沉",
"光通讯",
"传感器",
"MEMS",
"市场",
"投资",
"设备",
"工艺",
"产线",
"制造",
"质量",
"ISO",
"客户",
"600+",
"芯片",
"散热",
"热沉",
"基地",
"规划",
]
@dataclass
class ImageRecord:
image_id: str
source_type: str
source_ppt: str
ppt_slug: str
slide_no: int
rel_path: str
abs_path: Path
width: int | None = None
height: int | None = None
sha256: str | None = None
note_path: str | None = None
title: str | None = None
reuse: str | None = None
@dataclass
class SlideRecord:
no: int
title: str
texts: list[str]
notes: list[str] = field(default_factory=list)
embedded_images: list[ImageRecord] = field(default_factory=list)
preview: ImageRecord | None = None
core_score: int = 0
@dataclass
class PPTRecord:
path: Path
slug: str
title: str
slides: list[SlideRecord]
def rel(path: Path) -> str:
return path.relative_to(WIKI_DIR).as_posix()
def kb_rel(path: Path) -> str:
return path.relative_to(KB_DIR).as_posix()
def clean_text(s: str) -> str:
s = re.sub(r"\s+", " ", s or "").strip()
s = s.replace(" ", "").replace(" ", "").replace("", "")
return s
def md_escape(s: str) -> str:
return s.replace("|", "\\|")
def slide_sort_key(name: str) -> int:
return int(re.search(r"slide(\d+)\.xml$", name).group(1))
def image_info(path: Path) -> tuple[int | None, int | None]:
try:
with Image.open(path) as im:
return im.size
except Exception:
return None, None
def sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def resolve_target(base: str, target: str) -> str:
if target.startswith("/"):
return target.lstrip("/")
base_dir = Path(base).parent
return (base_dir / target).as_posix().replace("ppt/slides/../", "ppt/")
def read_rels(z: zipfile.ZipFile, rels_name: str) -> dict[str, str]:
if rels_name not in z.namelist():
return {}
root = ET.fromstring(z.read(rels_name))
out: dict[str, str] = {}
for rel_el in root.findall("rel:Relationship", NS):
rid = rel_el.attrib.get("Id")
target = rel_el.attrib.get("Target")
if rid and target:
out[rid] = target
return out
def extract_texts(root: ET.Element) -> list[str]:
raw = [clean_text(t.text or "") for t in root.findall(".//a:t", NS)]
texts = [t for t in raw if t]
merged: list[str] = []
for t in texts:
if not merged or merged[-1] != t:
merged.append(t)
return merged
def parse_xml(data: bytes) -> ET.Element:
try:
return ET.fromstring(data)
except ET.ParseError:
text = data.decode("utf-8", errors="ignore").strip()
if text.endswith("<p:sld/>") or text.endswith("<p:sld />"):
return ET.fromstring(f'<p:sld xmlns:p="{NS["p"]}"/>')
raise
def title_from_texts(texts: list[str], fallback: str) -> str:
for t in texts:
if len(t) >= 2 and not re.fullmatch(r"\d+|[A-Za-z]{1,3}", t):
return t[:80]
return fallback
def core_score(text: str, slide_no: int) -> int:
score = 0
for kw in CORE_KEYWORDS:
if kw.lower() in text.lower():
score += 2
if any(k in text for k in SECTION_KEYWORDS) and len(text) < 120:
score -= 3
if slide_no <= 2:
score -= 1
return score
def extract_pptx(ppt: Path) -> PPTRecord:
slug, title = PPT_SLUGS.get(ppt.name, (ppt.stem, ppt.stem))
slides: list[SlideRecord] = []
media_dir = ASSET_DIR / "media" / slug
media_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(ppt) as z:
names = set(z.namelist())
slide_names = sorted(
[n for n in names if re.match(r"ppt/slides/slide\d+\.xml$", n)],
key=slide_sort_key,
)
for slide_name in slide_names:
slide_no = slide_sort_key(slide_name)
root = parse_xml(z.read(slide_name))
texts = extract_texts(root)
title_text = title_from_texts(texts, f"Slide {slide_no}")
record = SlideRecord(
no=slide_no,
title=title_text,
texts=texts,
core_score=core_score(" ".join(texts), slide_no),
)
rels_name = f"ppt/slides/_rels/slide{slide_no}.xml.rels"
rels = read_rels(z, rels_name)
seen: set[str] = set()
pic_no = 0
for blip in root.findall(".//a:blip", NS):
rid = blip.attrib.get(f"{{{NS['r']}}}embed")
if not rid or rid not in rels:
continue
target = resolve_target(slide_name, rels[rid])
if target not in names or target in seen:
continue
seen.add(target)
pic_no += 1
ext = Path(target).suffix or ".bin"
out_name = f"{slug}_S{slide_no:03d}_IMG{pic_no:02d}{ext.lower()}"
out_path = media_dir / out_name
out_path.write_bytes(z.read(target))
w, h = image_info(out_path)
rec = ImageRecord(
image_id=f"IMG-{slug}-S{slide_no:03d}-{pic_no:02d}",
source_type="embedded",
source_ppt=ppt.name,
ppt_slug=slug,
slide_no=slide_no,
rel_path=kb_rel(out_path),
abs_path=out_path,
width=w,
height=h,
sha256=sha256(out_path),
title=f"{title_text} - 内嵌图片 {pic_no}",
reuse="按图片内容复核后,可用于公司介绍、产品展示、设备/工艺说明或客户验厂材料。",
)
record.embedded_images.append(rec)
slides.append(record)
return PPTRecord(path=ppt, slug=slug, title=title, slides=slides)
def render_slide_previews(records: list[PPTRecord]) -> None:
libreoffice = shutil.which("libreoffice") or shutil.which("soffice")
pdftoppm = shutil.which("pdftoppm")
if not libreoffice or not pdftoppm:
print("WARN: LibreOffice or pdftoppm missing; slide previews skipped", file=sys.stderr)
return
TMP_DIR.mkdir(parents=True, exist_ok=True)
(ASSET_DIR / "slides").mkdir(parents=True, exist_ok=True)
for ppt in records:
pdf_dir = TMP_DIR / ppt.slug
pdf_dir.mkdir(parents=True, exist_ok=True)
subprocess.run(
[libreoffice, "--headless", "--convert-to", "pdf", "--outdir", str(pdf_dir), str(ppt.path)],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
pdfs = sorted(pdf_dir.glob("*.pdf"))
if not pdfs:
print(f"WARN: no pdf generated for {ppt.path}", file=sys.stderr)
continue
pdf = pdfs[0]
out_dir = ASSET_DIR / "slides" / ppt.slug
out_dir.mkdir(parents=True, exist_ok=True)
prefix = out_dir / "page"
subprocess.run(
[pdftoppm, "-jpeg", "-jpegopt", "quality=90", "-r", "144", str(pdf), str(prefix)],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
rendered = sorted(out_dir.glob("page-*.jpg"), key=lambda p: int(re.search(r"-(\d+)\.jpg$", p.name).group(1)))
for idx, path in enumerate(rendered, start=1):
target = out_dir / f"slide-{idx:03d}.jpg"
path.replace(target)
for leftover in out_dir.glob("page-*.jpg"):
leftover.unlink(missing_ok=True)
for slide in ppt.slides:
image_path = out_dir / f"slide-{slide.no:03d}.jpg"
if not image_path.exists():
continue
w, h = image_info(image_path)
rec = ImageRecord(
image_id=f"SLIDE-{ppt.slug}-S{slide.no:03d}",
source_type="slide-preview",
source_ppt=ppt.path.name,
ppt_slug=ppt.slug,
slide_no=slide.no,
rel_path=kb_rel(image_path),
abs_path=image_path,
width=w,
height=h,
sha256=sha256(image_path),
title=slide.title,
reuse="可直接作为路演素材检索预览;正式交付前可按版式重绘或截取局部。",
)
slide.preview = rec
def paragraph(lines: list[str]) -> str:
return "\n".join(lines).strip() + "\n"
def write_source_notes(records: list[PPTRecord]) -> list[ImageRecord]:
source_dir = WIKI_DIR / "Sources" / "PPT"
source_dir.mkdir(parents=True, exist_ok=True)
all_images: list[ImageRecord] = []
for ppt in records:
source_rel = Path("..") / ".." / ".." / ppt.path.relative_to(ROOT)
out = source_dir / f"{ppt.slug}.md"
lines = [
"---",
"source_type: pptx",
f"title: {ppt.title}",
f"source_path: {source_rel.as_posix()}",
f"date_ingested: {INGEST_TIME}",
f"slides: {len(ppt.slides)}",
"---",
"",
f"# {ppt.title}",
"",
"## Summary",
"",
"本页为 PPT 来源笔记,保留逐页文本、幻灯片预览图和内嵌图片索引。稳定结论已提升到 `Knowledge/` 页面;需要复用图片时优先检索 `Sources/Images/图片快速索引.md`。",
"",
"## Source",
"",
f"- 原始文件:`{source_rel.as_posix()}`",
f"- 入库时间:`{INGEST_TIME}`",
f"- 幻灯片数量:{len(ppt.slides)}",
"",
"## Slide Inventory",
"",
]
for slide in ppt.slides:
full_text = " ".join(slide.texts)
lines.extend(
[
f"### Slide {slide.no:03d}: {slide.title}",
"",
f"- Core score: `{slide.core_score}`",
]
)
if slide.preview:
lines.append(f"- Preview ID: `{slide.preview.image_id}`")
lines.append(f"- Preview path: `{slide.preview.rel_path}`")
lines.append(f"![{md_escape(slide.title)}](../../../{slide.preview.rel_path})")
all_images.append(slide.preview)
if slide.embedded_images:
lines.append("- Embedded images:")
for img in slide.embedded_images:
lines.append(f" - `{img.image_id}``{img.rel_path}` ({img.width or '?'}x{img.height or '?'})")
all_images.append(img)
lines.extend(["", "Text:", ""])
if full_text:
wrapped = textwrap.fill(full_text, width=120)
lines.extend([wrapped, ""])
else:
lines.extend(["(本页未抽取到可编辑文本,需查看预览图。)", ""])
out.write_text("\n".join(lines), encoding="utf-8")
return all_images
def image_note_name(image_id: str) -> str:
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", image_id)
return f"{safe}.md"
def write_image_notes(images: list[ImageRecord], slide_text_by_id: dict[str, str]) -> None:
img_dir = WIKI_DIR / "Sources" / "Images"
img_dir.mkdir(parents=True, exist_ok=True)
index_lines = [
"# 图片快速索引",
"",
f"Date ingested: `{INGEST_TIME}`",
"",
"用途:为公司介绍、路演材料、验厂材料、产品展示和技术路线图快速定位 PPT 图片。调用时优先使用 Image ID并读取对应图片笔记。",
"",
"## 快速调用口令",
"",
"```text",
"请读取 博志金钻项目基本情况_知识库/wiki/Sources/Images/图片快速索引.md按 Image ID 调用指定图片,并结合对应 Sources/PPT 来源页核对上下文。",
"```",
"",
"## 核心幻灯片预览图",
"",
"| Image ID | 来源 | 页码 | 标题/用途 | 尺寸 | 路径 |",
"|---|---|---:|---|---:|---|",
]
embedded_lines = [
"",
"## PPT内嵌原图",
"",
"| Image ID | 来源 | 页码 | 标题/用途 | 尺寸 | 路径 |",
"|---|---|---:|---|---:|---|",
]
for img in images:
note_rel = Path("Sources") / "Images" / image_note_name(img.image_id)
img.note_path = note_rel.as_posix()
text = slide_text_by_id.get(f"{img.ppt_slug}:{img.slide_no}", "")
note = [
"---",
f"image_id: {img.image_id}",
f"source_type: {img.source_type}",
f"source_ppt: {img.source_ppt}",
f"slide_no: {img.slide_no}",
f"image_path: ../../../{img.rel_path}",
f"date_ingested: {INGEST_TIME}",
"---",
"",
f"# Image: {img.title or img.image_id}",
"",
f"Image path: `../../../{img.rel_path}`",
f"Source document: `{img.source_ppt}`",
f"Original slide: `{img.slide_no}`",
f"Date ingested: `{INGEST_TIME}`",
f"Image ID: `{img.image_id}`",
"",
"## Preview",
"",
f"![{md_escape(img.title or img.image_id)}](../../../{img.rel_path})",
"",
"## Visual Description",
"",
("整页幻灯片预览图,包含该页的版式、文字、图片和图表。" if img.source_type == "slide-preview" else "PPT 内嵌原图;需结合所在幻灯片预览核对语境。"),
"",
"## Extracted Labels / OCR",
"",
textwrap.fill(text[:1800], width=120) if text else "(未抽取到同页文本。)",
"",
"## What This Image Supports",
"",
"- 支撑公司基本情况知识库中的来源追溯、素材复用和路演图像定位。",
"- 若用于外部交付,应回到原始 PPT 或高清原图确认分辨率、版权授权和上下文。",
"",
"## Limitations",
"",
"- 自动抽取不能替代人工审图小图标、Logo、装饰元素可能被作为内嵌图片列出。",
"- 幻灯片预览图适合快速调用和语境确认,正式设计建议重绘或使用源文件导出高清版本。",
"",
"## Reuse Plan",
"",
f"- {img.reuse}",
"",
"## Links",
"",
f"- Source PPT note: `../PPT/{img.ppt_slug}.md`",
"",
]
(WIKI_DIR / note_rel).write_text("\n".join(note), encoding="utf-8")
size = f"{img.width or '?'}x{img.height or '?'}"
row = f"| `{img.image_id}` | {md_escape(img.source_ppt)} | {img.slide_no} | {md_escape(img.title or '')} | {size} | `../../../{img.rel_path}` |"
if img.source_type == "slide-preview":
index_lines.append(row)
else:
embedded_lines.append(row)
(img_dir / "图片快速索引.md").write_text("\n".join(index_lines + embedded_lines) + "\n", encoding="utf-8")
def write_registry(records: list[PPTRecord], images: list[ImageRecord]) -> None:
sys_dir = WIKI_DIR / "_system"
sys_dir.mkdir(parents=True, exist_ok=True)
lines = [
"# Registry",
"",
f"Date ingested: `{INGEST_TIME}`",
"",
"## PPT Sources",
"",
"| Source ID | Title | Slides | Path |",
"|---|---|---:|---|",
]
for rec in records:
lines.append(f"| `{rec.slug}` | {md_escape(rec.title)} | {len(rec.slides)} | `Sources/PPT/{rec.slug}.md` |")
lines.extend(
[
"",
"## Image Counts",
"",
f"- 幻灯片预览图:{sum(1 for i in images if i.source_type == 'slide-preview')}",
f"- PPT 内嵌原图:{sum(1 for i in images if i.source_type == 'embedded')}",
f"- 图片总数:{len(images)}",
]
)
(sys_dir / "registry.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
schema = [
"# Schema",
"",
"- 原始 PPT 位于 `博志金钻项目基本情况/`,作为 Raw Sources不由 Agent 改写。",
"- 新增或更新 PPT 后,先运行 `_system/build_company_kb.py` 重新抽取来源笔记和图片索引。",
"- 稳定事实写入 `Knowledge/`;直接引用 PPT 内容时回链到 `Sources/PPT/` 和 `Sources/Images/`。",
"- 所有可复用图片必须使用本地路径,优先用 Image ID 调用。",
"- `log.md` 只追加,不重写历史。",
]
(sys_dir / "schema.md").write_text("\n".join(schema) + "\n", encoding="utf-8")
def main() -> None:
if not SOURCE_DIR.exists():
raise SystemExit(f"missing source dir: {SOURCE_DIR}")
for p in [
WIKI_DIR / "Sources" / "PPT",
WIKI_DIR / "Sources" / "Images",
WIKI_DIR / "Knowledge",
WIKI_DIR / "Writing",
WIKI_DIR / "Maps",
WIKI_DIR / "Daily",
WIKI_DIR / "Archive",
WIKI_DIR / "_system",
ASSET_DIR / "media",
ASSET_DIR / "slides",
]:
p.mkdir(parents=True, exist_ok=True)
pptx_files = sorted(SOURCE_DIR.glob("*.pptx"))
records = [extract_pptx(p) for p in pptx_files]
render_slide_previews(records)
all_images = write_source_notes(records)
slide_text_by_id = {
f"{ppt.slug}:{slide.no}": " ".join(slide.texts)
for ppt in records
for slide in ppt.slides
}
write_image_notes(all_images, slide_text_by_id)
write_registry(records, all_images)
shutil.rmtree(TMP_DIR, ignore_errors=True)
print(f"ingested {len(records)} pptx files")
print(f"slides: {sum(len(r.slides) for r in records)}")
print(f"images: {len(all_images)}")
print(f"kb: {KB_DIR}")
if __name__ == "__main__":
main()