Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
157 lines
6.4 KiB
Python
157 lines
6.4 KiB
Python
"""Structural (not just text) extraction from PPTX/XLSX files — layouts,
|
|
fonts, colors, cell formatting — so a template's exact look can be captured
|
|
into a Skill (see ``skills.py::generate_skill_from_template``).
|
|
|
|
``doc_extract.py`` already extracts these formats, but ONLY plain text — every
|
|
font/color/layout/formatting detail is discarded there. This module is the
|
|
opposite: it skips body text (already covered by ``doc_extract``) and instead
|
|
summarizes the STYLE, as a compact, LLM-readable text block (not raw XML/JSON).
|
|
|
|
Uses ``python-pptx``/``openpyxl`` (auto-installed on first use via
|
|
``deps.ensure_module``, same pattern as ``doc_extract._pdf``'s pypdf).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from .deps import ensure_module
|
|
|
|
MAX_SLIDES = 60
|
|
MAX_SHEETS = 20
|
|
MAX_ROWS_PER_SHEET = 100 # a style summary only needs a representative sample
|
|
_RUN_PREVIEW_CHARS = 40
|
|
|
|
|
|
def _run_style(font) -> str:
|
|
"""Mô tả định dạng một đoạn chữ (đậm, nghiêng, cỡ, màu) thành chuỗi ngắn.
|
|
|
|
Dùng để AI sửa tài liệu mà vẫn giữ được định dạng gốc.
|
|
"""
|
|
bits: list[str] = []
|
|
try:
|
|
if font.name:
|
|
bits.append(font.name)
|
|
if font.size:
|
|
bits.append(f"{font.size.pt:g}pt")
|
|
if font.bold:
|
|
bits.append("bold")
|
|
if font.italic:
|
|
bits.append("italic")
|
|
rgb = None
|
|
try:
|
|
rgb = font.color.rgb if font.color and font.color.type else None
|
|
except (AttributeError, KeyError, TypeError):
|
|
pass # theme color or no color set — not an RGB value
|
|
if rgb:
|
|
bits.append(f"#{rgb}")
|
|
except Exception: # noqa: BLE001 - a single malformed run must not abort extraction
|
|
pass
|
|
return ", ".join(bits) or "default style"
|
|
|
|
|
|
def extract_pptx_structure(path) -> str:
|
|
"""Per-slide layout name + each text run's font/size/bold/italic/color."""
|
|
pptx_mod = ensure_module("pptx", "python-pptx")
|
|
if pptx_mod is None:
|
|
return ""
|
|
try:
|
|
prs = pptx_mod.Presentation(str(path))
|
|
slides = list(prs.slides)
|
|
except Exception: # noqa: BLE001 - corrupt/unreadable file: degrade like a missing dependency
|
|
return ""
|
|
lines: list[str] = []
|
|
for i, slide in enumerate(slides[:MAX_SLIDES], 1):
|
|
layout_name = "?"
|
|
try:
|
|
if slide.slide_layout is not None:
|
|
layout_name = slide.slide_layout.name
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
lines.append(f"Slide {i} (layout: {layout_name}):")
|
|
for shape in slide.shapes:
|
|
if not getattr(shape, "has_text_frame", False):
|
|
continue
|
|
ph_type = ""
|
|
try:
|
|
if shape.is_placeholder:
|
|
ph_type = f" [{shape.placeholder_format.type}]"
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
for para in shape.text_frame.paragraphs:
|
|
for run in para.runs:
|
|
text = run.text.strip()
|
|
if not text:
|
|
continue
|
|
preview = text[:_RUN_PREVIEW_CHARS]
|
|
lines.append(f' - {shape.shape_type}{ph_type} text "{preview}" — {_run_style(run.font)}')
|
|
if len(slides) > MAX_SLIDES:
|
|
lines.append(f"... ({len(slides) - MAX_SLIDES} more slides truncated)")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def extract_xlsx_structure(path) -> str:
|
|
"""Per-sheet header styling, column widths, merged cells, number formats."""
|
|
openpyxl_mod = ensure_module("openpyxl", "openpyxl")
|
|
if openpyxl_mod is None:
|
|
return ""
|
|
try:
|
|
wb = openpyxl_mod.load_workbook(str(path), data_only=False)
|
|
except Exception: # noqa: BLE001 - corrupt/unreadable file: degrade like a missing dependency
|
|
return ""
|
|
lines: list[str] = []
|
|
for ws in wb.worksheets[:MAX_SHEETS]:
|
|
lines.append(f'Sheet "{ws.title}" ({ws.dimensions}):')
|
|
try:
|
|
ranges = list(ws.merged_cells.ranges)
|
|
except Exception: # noqa: BLE001
|
|
ranges = []
|
|
if ranges:
|
|
lines.append(f" merged cells: {', '.join(str(r) for r in ranges[:20])}")
|
|
widths = [f"{letter}={dim.width:g}" for letter, dim in list(ws.column_dimensions.items())[:20]
|
|
if dim.width]
|
|
if widths:
|
|
lines.append(f" column widths: {', '.join(widths)}")
|
|
header_cells = []
|
|
for cell in next(ws.iter_rows(min_row=1, max_row=1), []):
|
|
if cell.value is None:
|
|
continue
|
|
bits = []
|
|
try:
|
|
if cell.font and cell.font.bold:
|
|
bits.append("bold")
|
|
if cell.font and cell.font.name:
|
|
bits.append(cell.font.name)
|
|
if cell.font and cell.font.size:
|
|
bits.append(f"{cell.font.size:g}pt")
|
|
fg = getattr(getattr(cell, "fill", None), "fgColor", None)
|
|
fill_rgb = getattr(fg, "rgb", None)
|
|
if fill_rgb and fill_rgb not in ("00000000", None):
|
|
bits.append(f"fill #{fill_rgb}")
|
|
if cell.number_format and cell.number_format != "General":
|
|
bits.append(f"format={cell.number_format}")
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
style = ", ".join(bits) or "default style"
|
|
header_cells.append(f'{cell.coordinate}="{cell.value}" ({style})')
|
|
if header_cells:
|
|
lines.append(" header row: " + "; ".join(header_cells))
|
|
formats: dict[str, str] = {}
|
|
last_row = min(ws.max_row or 1, MAX_ROWS_PER_SHEET)
|
|
if last_row >= 2:
|
|
for row in ws.iter_rows(min_row=2, max_row=last_row):
|
|
for cell in row:
|
|
if cell.value is not None and cell.number_format != "General":
|
|
formats.setdefault(cell.column_letter, cell.number_format)
|
|
if formats:
|
|
lines.append(" column number formats: " + ", ".join(f"{k}={v}" for k, v in formats.items()))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def extract_structure(path) -> str:
|
|
"""Dispatch by suffix. Returns "" for an unsupported type or a missing
|
|
optional dependency — callers should fall back to plain text in that case."""
|
|
suffix = str(path).lower().rsplit(".", 1)[-1] if "." in str(path) else ""
|
|
if suffix == "pptx":
|
|
return extract_pptx_structure(path)
|
|
if suffix in ("xlsx", "xlsm"):
|
|
return extract_xlsx_structure(path)
|
|
return ""
|