feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)

Real IMAP connection via imapflow library:
- Connects to IMAP server (IMAPFlow, TLS on port 993)
- Fetches recent messages (configurable limit, default 20)
- Extracts: from, fromEmail, subject, date, snippet (200 chars)
- Thread-to-contact matching (PRD §6.1):
  1. Match sender email against persons table
  2. If no person match, extract domain and match against company.website
  3. Returns matchedCompany + matchedPerson for each email
- Audit-logged (action: crm.mailsync:imap)
- Zod validated (host, port, user, password, folder, limit)
- 502 on IMAP connection failure with detail
- 400 on validation failure

Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
This commit is contained in:
Z User committed 2026-10-27 22:50:50 +00:00
1 parent 7acc0c5451
commit e45b68bb8b
1089 files changed
+260614

No files matched your search

+402
View File
@@ -0,0 +1,402 @@
#!/usr/bin/env python3
"""将解析后的志愿 JSON + 推荐分析套用 HTML 模板,生成可视化报告。"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader, select_autoescape
SKILL_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_TEMPLATE = SKILL_ROOT / "templates" / "volunteer_report.html"
VOLUNTEER_TYPE_LABELS = {
"ACADEMY_MAJOR": "院校志愿",
"ACADEMY_GROUP": "专业组",
"MAJOR_GROUP": "专业平行志愿",
}
TYPE_CSS = {
"冲": "chong",
"稳": "wen",
"保": "bao",
"难": "nan",
"易": "yi",
}
SECTION_ORDER = ["冲", "稳", "保", "易", "难"]
SECTION_TITLE_CLASS = {
"冲": "title-chong",
"稳": "title-wen",
"保": "title-bao",
"易": "title-wen",
"难": "title-chong",
}
def format_subjects(subjects: str | None) -> str:
if not subjects:
return "—"
parts = [p.strip() for p in re.split(r"[,,]", subjects) if p.strip()]
return "·".join(parts) if parts else subjects
def split_tags(tags: str | None, limit: int = 8) -> list[str]:
if not tags:
return []
items = [t.strip() for t in re.split(r"[,,]", tags) if t.strip()]
return items[:limit]
def build_school_tags(school: dict[str, Any]) -> list[str]:
tags: list[str] = []
primary_parts = []
if school.get("property_name"):
primary_parts.append(school["property_name"])
if school.get("university_code"):
primary_parts.append(f"代码{school['university_code']}")
if primary_parts:
tags.append(" · ".join(primary_parts))
loc_parts = []
if school.get("province_name"):
loc_parts.append(school["province_name"])
if school.get("city_name"):
loc_parts.append(school["city_name"])
if loc_parts:
tags.append("".join(loc_parts))
tags.extend(split_tags(school.get("tags")))
return tags
def enrich_major(major: dict, rec_id: str | None = None) -> dict:
major = dict(major)
cost = major.get("study_cost")
if cost and not str(cost).endswith("元"):
major["study_cost_display"] = f"{cost}元"
else:
major["study_cost_display"] = cost or "—"
major["rec_id"] = rec_id
return major
def enrich_school(school: dict) -> dict:
label = school.get("type_label") or ""
school = dict(school)
school["type_css"] = TYPE_CSS.get(label, "default")
school["tag_list"] = build_school_tags(school)
school["majors"] = [enrich_major(m) for m in school.get("majors") or []]
school["has_details"] = bool(school.get("score_history") or school.get("majors"))
return school
def university_group_key(school: dict) -> tuple[str, str]:
return (school.get("university_name") or "", school.get("university_code") or "")
def collect_probabilities(schools: list[dict]) -> list[int]:
probs: list[int] = []
for school in schools:
if school.get("enroll_probability") is not None:
probs.append(int(school["enroll_probability"]))
for major in school.get("majors") or []:
if major.get("enroll_probability") is not None:
probs.append(int(major["enroll_probability"]))
return probs
def format_prob_range(probs: list[int]) -> tuple[int | None, int | None, str | None]:
if not probs:
return None, None, None
low, high = min(probs), max(probs)
if low == high:
return low, high, f"{low}%"
return low, high, f"{low}%–{high}%"
def sort_majors_by_rec(majors: list[dict]) -> list[dict]:
"""推荐专业排在前面,其余保持相对顺序。"""
return sorted(majors, key=lambda m: (0 if m.get("rec_id") else 1))
def university_rec_rank(uni: dict) -> int:
"""同档位院校排序:推荐学校+推荐专业 > 仅推荐学校 > 其他。"""
has_school_rec = bool(uni.get("rec_id"))
has_major_rec = False
for m in uni.get("flat_majors") or []:
if m.get("rec_id"):
has_major_rec = True
break
if not has_major_rec:
for g in uni.get("groups") or []:
if any(m.get("rec_id") for m in g.get("majors") or []):
has_major_rec = True
break
if has_school_rec and has_major_rec:
return 0
if has_school_rec:
return 1
return 2
def build_major_group_entry(school: dict, major_rec_map: dict[tuple, str]) -> dict:
majors = []
for m in school.get("majors") or []:
key = (school.get("university_name"), school.get("university_code"), m.get("major_name"), m.get("major_code"))
rec_id = major_rec_map.get(key)
majors.append(enrich_major(m, rec_id))
return {
"major_group": school.get("major_group") or "",
"enroll_probability": school.get("enroll_probability"),
"score_history": school.get("score_history") or [],
"majors": sort_majors_by_rec(majors),
"has_content": bool(school.get("score_history") or majors),
}
def flatten_majors(groups: list[dict]) -> list[dict]:
majors: list[dict] = []
for g in groups:
for m in g.get("majors") or []:
row = dict(m)
row["entry_probability"] = g.get("enroll_probability")
row["entry_score_history"] = g.get("score_history") or []
majors.append(row)
return sort_majors_by_rec(majors)
def build_rec_maps(analysis: dict | None) -> tuple[dict[tuple, str], dict[tuple, str], dict[str, dict]]:
"""返回 (school_rec_map, major_rec_map, rec_modals)。"""
if not analysis:
return {}, {}, {}
modals: dict[str, dict] = {}
school_map: dict[tuple, str] = {}
major_map: dict[tuple, str] = {}
for i, s in enumerate(analysis.get("recommended_schools") or []):
rid = f"school-{i}"
key = (s.get("university_name"), s.get("university_code"))
school_map[key] = rid
modal = s.get("modal") or {}
modals[rid] = {"title": modal.get("title", ""), "body": modal.get("body", "")}
for i, m in enumerate(analysis.get("recommended_majors") or []):
rid = f"major-{i}"
key = (m.get("university_name"), m.get("university_code"), m.get("major_name"), m.get("major_code"))
major_map[key] = rid
modal = m.get("modal") or {}
modals[rid] = {"title": modal.get("title", ""), "body": modal.get("body", "")}
return school_map, major_map, modals
def aggregate_universities(schools: list[dict], school_rec_map: dict, major_rec_map: dict) -> list[dict]:
buckets: dict[tuple[str, str], list[dict]] = {}
order: list[tuple[str, str]] = []
for school in schools:
key = university_group_key(school)
if key not in buckets:
buckets[key] = []
order.append(key)
buckets[key].append(enrich_school(school))
aggregated: list[dict] = []
for display_index, key in enumerate(order, start=1):
group_schools = sorted(buckets[key], key=lambda s: s.get("index") or 0)
primary = group_schools[0]
groups = [build_major_group_entry(s, major_rec_map) for s in group_schools]
probs = collect_probabilities(group_schools)
prob_min, prob_max, prob_display = format_prob_range(probs)
major_groups = [g["major_group"] for g in groups if g["major_group"]]
has_major_group = len(major_groups) > 0
if len(major_groups) == 1:
group_subtitle = f"/ 专业组 {major_groups[0]}"
elif len(major_groups) > 1:
group_subtitle = f"/ {len(major_groups)}个专业组"
else:
group_subtitle = ""
flat_majors = flatten_majors(groups) if not has_major_group else []
uni_key = (primary.get("university_name"), primary.get("university_code"))
aggregated.append(
{
"index": display_index,
"sort_index": min(s.get("index") or display_index for s in group_schools),
"university_name": primary.get("university_name") or "",
"university_code": primary.get("university_code") or "",
"type_css": primary.get("type_css", "default"),
"tag_list": primary.get("tag_list") or [],
"group_subtitle": group_subtitle,
"group_count": len(groups),
"prob_min": prob_min,
"prob_max": prob_max,
"prob_display": prob_display,
"groups": groups,
"has_major_group": has_major_group,
"flat_majors": flat_majors,
"flat_major_count": len(flat_majors),
"has_details": any(g["has_content"] for g in groups),
"rec_id": school_rec_map.get(uni_key),
}
)
aggregated.sort(key=lambda u: (university_rec_rank(u), u["sort_index"]))
for i, uni in enumerate(aggregated, start=1):
uni["index"] = i
return aggregated
def build_sections(schools_by_type: dict[str, list], school_rec_map: dict, major_rec_map: dict) -> list[dict]:
sections = []
seen = set()
for label in SECTION_ORDER:
schools = schools_by_type.get(label)
if not schools:
continue
seen.add(label)
universities = aggregate_universities(schools, school_rec_map, major_rec_map)
sections.append(
{
"label": label,
"title_class": SECTION_TITLE_CLASS.get(label, ""),
"count": len(universities),
"universities": universities,
}
)
for label, schools in schools_by_type.items():
if label in seen or not schools:
continue
universities = aggregate_universities(schools, school_rec_map, major_rec_map)
sections.append(
{
"label": label,
"title_class": "",
"count": len(universities),
"universities": universities,
}
)
return sections
def compute_stat_pcts(stats: dict[str, Any]) -> dict[str, int]:
total = stats.get("total") or 0
if total <= 0:
return {"chong": 0, "wen": 0, "bao": 0}
return {
"chong": round((stats.get("chong") or 0) / total * 100),
"wen": round((stats.get("wen") or 0) / total * 100),
"bao": round((stats.get("bao") or 0) / total * 100),
}
def enrich_context(data: dict, analysis: dict | None = None) -> dict:
profile = data.get("profile") or {}
stats = data.get("stats") or {}
schools_by_type = data.get("schools_by_type") or {}
school_rec_map, major_rec_map, rec_modals = build_rec_maps(analysis)
score = profile.get("score")
score_display = f"{score} 分" if score is not None else "—"
if profile.get("rank"):
score_display += f" / 位次 {profile['rank']}"
narrative = (analysis or {}).get("analysis") or {}
student = (analysis or {}).get("student") or {}
return {
"title": "高考志愿智能推荐方案",
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
"profile": profile,
"student": student,
"subjects_display": format_subjects(profile.get("subjects")),
"score_display": score_display,
"stats": stats,
"stat_pcts": compute_stat_pcts(stats),
"sections": build_sections(schools_by_type, school_rec_map, major_rec_map),
"volunteer_type_label": VOLUNTEER_TYPE_LABELS.get(
profile.get("volunteer_type") or "", profile.get("volunteer_type") or "—"
),
"is_adjust_label": "服从" if profile.get("is_adjust") else "不服从",
"has_analysis": bool(narrative),
"analysis": narrative,
"rec_modals_json": json.dumps(rec_modals, ensure_ascii=False),
}
def render_report(
data: dict,
template_path: Path,
output_path: Path,
analysis: dict | None = None,
) -> None:
if not template_path.is_file():
raise FileNotFoundError(f"模板不存在: {template_path}")
env = Environment(
loader=FileSystemLoader(str(template_path.parent)),
autoescape=select_autoescape(["html", "xml"]),
)
template = env.get_template(template_path.name)
html = template.render(**enrich_context(data, analysis))
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(html, encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="生成志愿推荐 HTML 报告")
parser.add_argument("-i", "--input", type=Path, required=True, help="fetch_volunteers.py 输出的 JSON")
parser.add_argument("-o", "--output", type=Path, required=True, help="HTML 输出路径")
parser.add_argument("-a", "--analysis", type=Path, help="Agent 产出的 analysis.json")
parser.add_argument(
"-t",
"--template",
type=Path,
default=DEFAULT_TEMPLATE,
help=f"Jinja2 模板路径(默认 {DEFAULT_TEMPLATE})",
)
args = parser.parse_args()
if not args.input.is_file():
print(f"错误: 输入文件不存在 {args.input}", file=sys.stderr)
return 1
try:
data = json.loads(args.input.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
print(f"错误: JSON 解析失败 — {e}", file=sys.stderr)
return 1
analysis = None
if args.analysis:
if not args.analysis.is_file():
print(f"错误: 分析文件不存在 {args.analysis}", file=sys.stderr)
return 1
try:
analysis = json.loads(args.analysis.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
print(f"错误: analysis JSON 解析失败 — {e}", file=sys.stderr)
return 1
try:
render_report(data, args.template, args.output, analysis)
except Exception as e:
print(f"错误: {e}", file=sys.stderr)
return 1
print(f"已生成 HTML 报告: {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""合并 major_recommendation.json 与 school_recommendation.json 为 analysis.json。"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def load_json(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
raise RuntimeError(f"无法读取 {path}: {e}") from e
def merge(majors_data: dict, schools_data: dict) -> dict:
return {
"student": majors_data.get("student") or {},
"analysis": {
"intro": majors_data.get("intro") or "",
"strategy": majors_data.get("strategy") or "",
"major_directions": majors_data.get("major_directions") or [],
"school_strategy": schools_data.get("school_strategy") or "",
},
"recommended_majors": majors_data.get("recommended_majors") or [],
"recommended_schools": schools_data.get("recommended_schools") or [],
}
def main() -> int:
parser = argparse.ArgumentParser(description="合并专业/院校推荐为 analysis.json")
parser.add_argument("--majors", type=Path, required=True, help="major_recommendation.json")
parser.add_argument("--schools", type=Path, required=True, help="school_recommendation.json")
parser.add_argument("-o", "--output", type=Path, required=True, help="输出 analysis.json")
args = parser.parse_args()
try:
majors_data = load_json(args.majors)
schools_data = load_json(args.schools)
result = merge(majors_data, schools_data)
except RuntimeError as e:
print(f"错误: {e}", file=sys.stderr)
return 1
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(
f"已合并: {args.output}(专业 {len(result['recommended_majors'])},"
f"学校 {len(result['recommended_schools'])})"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())