feat: 工资查询助手首版 v1.0.105

基于 skill-template 脚手架开发,包含:
- payroll_config(费率配置,可编辑)+ payslip_snapshots(解析历史,只读)2 张业务表
- 全国默认 + 北上广 4 套费率(社保/公积金/个税起征点/7 级税率表)
- 5 个业务 Action:calculate-payslip-simple / calculate-payslip-full / explain-term / view-config / edit-config
- 个税累计预扣法计算,支持多月历史累加
- 20 个薪资名词库(社保/公积金/个税/全勤/加班费等)
- 完整市场文档(README/TUTORIAL/DEMO/CHANGELOG)
- 296 测试全部通过
This commit is contained in:
skill-dev
2026-09-07 17:02:05 +08:00
commit 3db196e49d
205 changed files with 29927 additions and 0 deletions

194
tools/highlight_answers.py Normal file
View File

@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Highlight correct answer options in Excel quiz file."""
import re
import sys
from pathlib import Path
import openpyxl
from openpyxl.cell.rich_text import CellRichText, InlineFont, TextBlock
# Option letter to index mapping
LETTER_TO_IDX = {"A": 0, "B": 1, "C": 2, "D": 3}
IDX_TO_LETTER = {0: "A", 1: "B", 2: "C", 3: "D"}
# Regex to find option boundaries: A/B/C/D followed by separator
OPTION_MARKER_RE = re.compile(
r"(?=[A-D](?:[\..、::\))]|\s))"
)
NORMAL_FONT = InlineFont(b=False, color="000000")
RED_BOLD_FONT = InlineFont(b=True, color="FF0000")
def parse_answer(answer_raw) -> set[int]:
"""Parse answer column into set of option indices (0=A, 1=B, ...)."""
if answer_raw is None:
return set()
s = str(answer_raw).strip()
if not s:
return set()
indices = set()
# Pure digits like "13" -> 1 and 3 (not 13)
if re.fullmatch(r"[1-4]+", s):
for ch in s:
indices.add(int(ch) - 1)
return indices
# Split by common separators
parts = re.split(r"[,,;;\s|]+", s)
for part in parts:
part = part.strip().upper()
if not part:
continue
if part in LETTER_TO_IDX:
indices.add(LETTER_TO_IDX[part])
elif re.fullmatch(r"[1-4]", part):
indices.add(int(part) - 1)
return indices
def find_option_spans(text: str) -> list[tuple[int, int, str]]:
"""Find spans for each A/B/C/D option in the options text."""
if not text or not isinstance(text, str):
return []
markers = []
for m in OPTION_MARKER_RE.finditer(text):
letter = text[m.start()]
markers.append((m.start(), letter))
if not markers:
return []
spans = []
for i, (start, letter) in enumerate(markers):
end = markers[i + 1][0] if i + 1 < len(markers) else len(text)
spans.append((start, end, letter))
return spans
def build_rich_text(options_text: str, answer_indices: set[int]) -> CellRichText | str:
"""Build rich text with correct options in red bold."""
spans = find_option_spans(options_text)
if not spans:
return options_text
parts = []
pos = 0
for start, end, letter in spans:
if start > pos:
parts.append(TextBlock(NORMAL_FONT, options_text[pos:start]))
idx = LETTER_TO_IDX.get(letter)
font = RED_BOLD_FONT if idx is not None and idx in answer_indices else NORMAL_FONT
parts.append(TextBlock(font, options_text[start:end]))
pos = end
if pos < len(options_text):
parts.append(TextBlock(NORMAL_FONT, options_text[pos:]))
return CellRichText(*parts)
def load_workbook_from_xls(xls_path: Path) -> openpyxl.Workbook:
"""Load .xls file, converting via pandas/xlrd if needed."""
try:
import pandas as pd
xls_data = pd.read_excel(xls_path, sheet_name=None, header=None, dtype=str)
wb = openpyxl.Workbook()
# Remove default sheet
wb.remove(wb.active)
for sheet_name, df in xls_data.items():
ws = wb.create_sheet(title=str(sheet_name))
for r_idx, row in enumerate(df.itertuples(index=False), start=1):
for c_idx, val in enumerate(row, start=1):
if val is not None and str(val) != "nan":
ws.cell(row=r_idx, column=c_idx, value=str(val))
return wb
except Exception as e:
raise RuntimeError(f"Failed to read .xls file: {e}") from e
def find_header_columns(ws) -> tuple[int, int, int] | None:
"""Find header row and column indices for 题目选项 and 题目答案."""
for row_idx in range(1, min(ws.max_row + 1, 20)):
options_col = None
answer_col = None
for col_idx in range(1, ws.max_column + 1):
val = ws.cell(row=row_idx, column=col_idx).value
if val is None:
continue
s = str(val).strip()
if s == "题目选项":
options_col = col_idx
elif s == "题目答案":
answer_col = col_idx
if options_col is not None and answer_col is not None:
return row_idx, options_col, answer_col
return None
def process_sheet(ws, stats: dict) -> bool:
"""Process a single worksheet. Returns True if processed."""
header_info = find_header_columns(ws)
if header_info is None:
return False
header_row, options_col, answer_col = header_info
stats["sheets"] += 1
sheet_rows = 0
sheet_matches = 0
for row_idx in range(header_row + 1, ws.max_row + 1):
options_cell = ws.cell(row=row_idx, column=options_col)
answer_cell = ws.cell(row=row_idx, column=answer_col)
options_text = options_cell.value
if options_text is None:
continue
options_text = str(options_text).strip()
if not options_text:
continue
answer_indices = parse_answer(answer_cell.value)
if not answer_indices:
continue
sheet_rows += 1
sheet_matches += len(answer_indices)
rich = build_rich_text(options_text, answer_indices)
options_cell.value = rich
stats["rows"] += sheet_rows
stats["matches"] += sheet_matches
return True
def main():
src = Path(r"D:\下载\安全知识竞赛题库(1).xls")
dst = Path(r"D:\下载\安全知识竞赛题库_答案标红.xlsx")
if not src.exists():
print(f"ERROR: Source file not found: {src}")
sys.exit(1)
print(f"Reading: {src}")
wb = load_workbook_from_xls(src)
stats = {"sheets": 0, "rows": 0, "matches": 0}
for ws in wb.worksheets:
process_sheet(ws, stats)
wb.save(dst)
print(f"Saved: {dst}")
print(f"处理结果: {stats['sheets']} 个工作表, {stats['rows']} 行, {stats['matches']} 个匹配选项")
if __name__ == "__main__":
main()