Commit be7f8224 authored by 李文光's avatar 李文光

feat: 分析报告 v2 富报告(Phase B)——SSE生成+预览写回+JD逐条比对+打印水印, 修复风险条目JSON外漏

parent 09cee42c
...@@ -171,6 +171,7 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend ...@@ -171,6 +171,7 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend
| GET | `/api/ingest-queue` | 查看待确认采集队列 | | GET | `/api/ingest-queue` | 查看待确认采集队列 |
| POST | `/api/ingest-queue/clear` | 确认并清空采集队列 | | POST | `/api/ingest-queue/clear` | 确认并清空采集队列 |
| POST | `/api/reports` | 生成候选人分析报告(冻结 AI 评估快照 + 公网分享链接) | | POST | `/api/reports` | 生成候选人分析报告(冻结 AI 评估快照 + 公网分享链接) |
| POST | `/api/reports/generate-v2/stream` | v2 富报告 SSE 流式生成(预览 → 仅快照 / 写回) |
| GET | `/api/reports?candidateId=` | 列出候选人分析报告历史 | | GET | `/api/reports?candidateId=` | 列出候选人分析报告历史 |
| PATCH | `/api/reports/{reportId}` | 停用 / 重新启用分析报告分享 | | PATCH | `/api/reports/{reportId}` | 停用 / 重新启用分析报告分享 |
| GET | `/api/share/report/{reportId}` | 分析报告公网匿名只读(需链接内 token) | | GET | `/api/share/report/{reportId}` | 分析报告公网匿名只读(需链接内 token) |
......
...@@ -31,10 +31,14 @@ class ReportError(Exception): ...@@ -31,10 +31,14 @@ class ReportError(Exception):
super().__init__(detail) super().__init__(detail)
def build_snapshot(candidate: Candidate) -> dict[str, Any]: def build_snapshot(candidate: Candidate, analysis_override: dict[str, Any] | None = None) -> dict[str, Any]:
"""从候选人行 data JSON 冻结分享快照,只取对外安全字段。""" """从候选人行 data JSON 冻结分享快照,只取对外安全字段。
analysis_override:v2「仅快照(不写回)」分支直接传入新生成的富分析对象 R;
否则复用候选行里已持久化的 analysis(v1 路径)。
"""
data = parse_json(candidate.data, {}) data = parse_json(candidate.data, {})
analysis = data.get("analysis") analysis = analysis_override if analysis_override is not None else data.get("analysis")
if not isinstance(analysis, dict) or analysis.get("score") in (None, ""): if not isinstance(analysis, dict) or analysis.get("score") in (None, ""):
raise ReportError(400, "候选人尚未生成有效的 AI 评估报告,请先生成后再分享") raise ReportError(400, "候选人尚未生成有效的 AI 评估报告,请先生成后再分享")
return { return {
...@@ -80,8 +84,9 @@ def create_report( ...@@ -80,8 +84,9 @@ def create_report(
author_username: str, author_username: str,
author_name: str, author_name: str,
base_url: str, base_url: str,
analysis_override: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
snapshot = build_snapshot(candidate) snapshot = build_snapshot(candidate, analysis_override)
snapshot["author"] = {"username": author_username, "name": author_name} snapshot["author"] = {"username": author_username, "name": author_name}
snapshot["generatedAt"] = utc_now_iso() snapshot["generatedAt"] = utc_now_iso()
now = utc_now_iso() now = utc_now_iso()
......
"""分析报告(候选人报告)路由(ADR 0002)。 """分析报告(候选人报告)路由(ADR 0002 / Phase B v2)。
- POST /api/reports 为当前工作区候选人冻结一份分享快照 管理端点(登录态 + resolve_owner 隔离):
- GET /api/reports?candidateId=... 列出该候选人报告历史(附分享开关状态 - POST /api/reports 冻结快照并创建分享(可选 analysis 覆盖 → v2「仅快照」
- PATCH /api/reports/{report_id} 停用 / 重新启用({status: active|disabled} - GET /api/reports?candidateId=... 列出候选人报告历史(附分享开关状态
- GET /api/share/report/{report_id} 公网匿名只读(带不可猜 token,security.py 放行) - PATCH /api/reports/{report_id} 停用 / 重新启用
- POST /api/reports/generate-v2/stream v2 富报告 SSE 流式生成(LLM → 本地回退)
管理端点一律登录态 + resolve_owner 工作区隔离;公开只读端点独立于账号体系, 公开只读(匿名,security.py 放行 GET /api/share/):
路由内部校验 report_id + token,失败统一按「不存在/已失效」处理。 - GET /api/share/report/{report_id} 带不可猜 token 读取快照
""" """
import json
from collections.abc import AsyncIterator
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.app.config import get_settings from backend.app.config import get_settings
from backend.app.db import get_db from backend.app.db import get_db
from backend.app.dependencies import CurrentUser, get_current_user, resolve_owner from backend.app.dependencies import CurrentUser, get_current_user, resolve_owner
from backend.app.models import Candidate from backend.app.models import Candidate, Job
from backend.app.repositories.json_utils import parse_json
from backend.app.repositories.pool_repository import POOL_OWNER from backend.app.repositories.pool_repository import POOL_OWNER
from backend.app.repositories.report_repository import ( from backend.app.repositories.report_repository import (
ReportError, ReportError,
...@@ -27,6 +31,7 @@ from backend.app.repositories.report_repository import ( ...@@ -27,6 +31,7 @@ from backend.app.repositories.report_repository import (
read_public_report, read_public_report,
set_report_status, set_report_status,
) )
from backend.app.services.candidate_report_v2 import generate_v2_analysis
router = APIRouter() router = APIRouter()
...@@ -44,6 +49,15 @@ def _candidate_id(payload: dict[str, Any]) -> str: ...@@ -44,6 +49,15 @@ def _candidate_id(payload: dict[str, Any]) -> str:
return value return value
def _candidate_row(db: Session, candidate_id: str, owner: str) -> Candidate:
if owner == POOL_OWNER:
raise HTTPException(status_code=400, detail="人才池候选人的分析报告暂不支持,请先认领到工作区")
candidate = db.scalar(select(Candidate).where(Candidate.id == candidate_id, Candidate.owner_id == owner))
if candidate is None:
raise HTTPException(status_code=404, detail="候选人不存在或不属于当前工作区")
return candidate
@router.post("/reports") @router.post("/reports")
def generate_report( def generate_report(
payload: dict[str, Any], payload: dict[str, Any],
...@@ -52,12 +66,11 @@ def generate_report( ...@@ -52,12 +66,11 @@ def generate_report(
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner), owner: str = Depends(resolve_owner),
) -> dict[str, Any]: ) -> dict[str, Any]:
if owner == POOL_OWNER:
raise HTTPException(status_code=400, detail="人才池候选人的分析报告暂不支持,请先认领到工作区")
candidate_id = _candidate_id(payload) candidate_id = _candidate_id(payload)
candidate = db.scalar(select(Candidate).where(Candidate.id == candidate_id, Candidate.owner_id == owner)) candidate = _candidate_row(db, candidate_id, owner)
if candidate is None: analysis_override = payload.get("analysis")
raise HTTPException(status_code=404, detail="候选人不存在或不属于当前工作区") if analysis_override is not None and not isinstance(analysis_override, dict):
raise HTTPException(status_code=400, detail="analysis 参数必须是对象")
try: try:
report = create_report( report = create_report(
db, db,
...@@ -66,6 +79,7 @@ def generate_report( ...@@ -66,6 +79,7 @@ def generate_report(
author_username=user.username, author_username=user.username,
author_name=user.name, author_name=user.name,
base_url=_base_url(request), base_url=_base_url(request),
analysis_override=analysis_override,
) )
except ReportError as exc: except ReportError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
...@@ -113,6 +127,52 @@ def update_report_status( ...@@ -113,6 +127,52 @@ def update_report_status(
return {"ok": True, "report": report} return {"ok": True, "report": report}
@router.post("/reports/generate-v2/stream")
async def generate_v2_report_stream(
payload: dict[str, Any],
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner),
) -> StreamingResponse:
"""v2 富报告 SSE 流式生成(Phase B)。
在同步段先锁定候选/岗位数据,事件流只做异步 LLM/本地组装:
- {"type":"stage","stage":"...","message":"..."} 进度
- {"type":"done","analysis":{...}} 完成(v2 富对象 R)
- {"type":"error","message":"..."} 失败
"""
candidate_id = _candidate_id(payload)
candidate = _candidate_row(db, candidate_id, owner)
candidate_data = parse_json(candidate.data, {})
job_id = candidate.primary_job_id or candidate_data.get("jobId")
job_row = db.scalar(select(Job).where(Job.id == job_id, Job.owner_id == owner)) if job_id else None
job_data = parse_json(job_row.data, {}) if job_row else {}
if job_row is not None:
job_data = {**job_data, "id": job_row.id, "title": job_data.get("title") or job_row.title or ""}
async def event_stream() -> AsyncIterator[str]:
def emit(event: dict[str, Any]) -> str:
return f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
yield emit({"type": "stage", "stage": "preparing", "message": "正在读取候选人与岗位资料…"})
if job_row is None or not str(job_data.get("title") or "").strip():
yield emit({"type": "error", "message": "请先为候选人选择并确认关联岗位,再生成报告"})
return
yield emit({"type": "stage", "stage": "analyzing", "message": "AI 正在做初筛评估与逐条比对…"})
try:
analysis = await generate_v2_analysis(candidate_data, job_data)
except Exception as exc: # pragma: no cover - defensive
yield emit({"type": "error", "message": f"报告生成失败: {exc}"})
return
yield emit({"type": "done", "analysis": analysis})
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/share/report/{report_id}") @router.get("/share/report/{report_id}")
def public_share_report(report_id: str, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]: def public_share_report(report_id: str, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
"""公网匿名只读:不依赖登录态,报告路由在 security.py 已放行 GET。""" """公网匿名只读:不依赖登录态,报告路由在 security.py 已放行 GET。"""
......
"""v2 分析报告生成(Phase B,ADR 0002 追加)。
设计(与决策一致):
1. 先生成 v1 兼容基础评估(复用 analyze_resume 的 LLM → local-structured 回退链);
2. 再生成「对外富报告」字段(conclusion/grade/highlights/jdCompare/dimensionNotes),
LLM 优先、无 Key 时本地规则组装,绝不 500;
3. 输出单一富对象 R = 基础评估 + v2 字段 + version:2,供「预览 → 仅快照 / 写回」两分支复用。
铁律对齐 llm.py:候选人在发送前脱敏;岗位内部字段(_sanitize_job_for_external)不进 prompt;
自述优势不作为亮点/JD 比对证据。
"""
import json
from typing import Any
from backend.app.config import get_settings
from backend.app.services.llm import (
_chat_json,
_sanitize_job_for_external,
analyze_resume,
job_criteria,
)
_GRADE_RULES = ((85, "A"), (72, "B"), (60, "C"))
def _list(value: Any) -> list[str]:
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
return []
def grade_for_score(score: Any) -> str:
try:
value = int(round(float(score)))
except (TypeError, ValueError):
return "C"
for threshold, grade in _GRADE_RULES:
if value >= threshold:
return grade
return "D"
def _find_sentence(keyword: str, sentences: list[str]) -> str:
needle = str(keyword or "").strip().lower()
for sentence in sentences:
if needle and needle in str(sentence).lower():
return str(sentence)
return ""
def local_jd_compare(base: dict[str, Any], criteria: dict[str, list[str]]) -> list[dict[str, str]]:
hit_pool = [
*_list(base.get("hardMatch")),
*_list(base.get("strengths")),
*_list(base.get("projectEvidence")),
*_list(base.get("evidence")),
]
miss_pool = [*_list(base.get("risks")), *_list(base.get("interviewQuestions"))]
matched_terms = [str(item) for item in _list(base.get("evidence"))]
rows: list[dict[str, str]] = []
def add_row(requirement: str) -> None:
evidence = _find_sentence(requirement, hit_pool)
if evidence and any(token in evidence for token in ("待确认", "缺口", "未体现", "未在简历", "建议面试")):
conclusion = "待确认"
elif evidence:
conclusion = "匹配"
elif _find_sentence(requirement, miss_pool) or requirement in matched_terms or not requirement:
conclusion = "未体现"
else:
conclusion = "待确认"
rows.append({"requirement": requirement, "evidence": evidence, "conclusion": conclusion})
for requirement in criteria.get("mustHave") or []:
add_row(requirement)
for requirement in (criteria.get("niceToHave") or [])[:6]:
add_row(requirement)
for requirement in (criteria.get("knockout") or [])[:4]:
add_row(requirement)
return rows
def local_v2_fields(base: dict[str, Any], job: dict[str, Any], criteria: dict[str, list[str]]) -> dict[str, Any]:
score = base.get("score")
decision = str(base.get("decision") or "建议进入初筛")
summary = str(base.get("summary") or "")
highlights = _list(base.get("strengths"))[:4] or ["暂无突出亮点,建议结合简历与面试进一步核实。"]
risks = _list(base.get("risks"))
risk_text = ";".join(risks[:3]) if risks else "暂未发现明显风险"
dimension_notes = {
"hard": ";".join(_list(base.get("hardMatch"))[:3]) or "见评估明细",
"experience": ";".join(_list(base.get("experienceMatch"))[:3]) or "见评估明细",
"evidence": ";".join(_list(base.get("projectEvidence"))[:3]) or "见评估明细",
"competency": ";".join(_list(base.get("competencyFrame"))[:3]) or "见评估明细",
"risk": risk_text,
}
return {
"version": 2,
"conclusion": f"{decision}({score} 分)。{summary}" if summary else f"{decision}({score} 分)。",
"grade": grade_for_score(score),
"highlights": highlights,
"jdCompare": local_jd_compare(base, criteria),
"dimensionNotes": dimension_notes,
"enrichProvider": "local",
"targetJob": str(job.get("title") or base.get("jobTitle") or ""),
}
async def llm_v2_fields(payload: dict[str, Any], base: dict[str, Any]) -> dict[str, Any] | None:
settings = get_settings()
if not settings.effective_llm_api_key:
return None
safe_job = _sanitize_job_for_external(payload.get("job") or {})
criteria = job_criteria(safe_job)
criteria_json = json.dumps(criteria, ensure_ascii=False, indent=2)
job_json = json.dumps({k: v for k, v in safe_job.items() if k not in ("criteria", "jdHistory")}, ensure_ascii=False, indent=2)[:6000]
candidate = payload.get("candidate") or {}
candidate_json = json.dumps({k: v for k, v in candidate.items() if k != "resumeText"}, ensure_ascii=False, indent=2)[:4000]
resume_text = (candidate.get("resumeText") or "")[:16000]
score = base.get("score")
risk_summary = ";".join(_list(base.get("risks"))[:3]) or "暂未发现明显风险"
user = (
"请基于下面的岗位要求与脱敏简历,产出一份对外初筛分析报告的细化字段,只返回 JSON,不要 Markdown。\n"
"字段与规则:\n"
"- conclusion:一句话结论(不超过 60 字),涵盖匹配要点与主要风险提示,与给定综合得分口径一致;\n"
"- grade:按综合得分定级:>=85 为 A,>=72 为 B,>=60 为 C,否则 D;\n"
"- highlights:核心亮点数组,每项 1 句话并带简历证据(如“主导 XX 项目,将 YY 提升 Z%”),最多 4 项,没有则给 1 条客观说明;\n"
"- jdCompare:逐条比对数组,每项含 requirement、evidence、conclusion 三个字段:requirement 取岗位硬性/加分/排除要求原文;"
"evidence 是候选人简历中的证据短句(无证据给空字符串);conclusion 只能是“匹配 / 部分匹配 / 未体现 / 待确认”之一;\n"
"- dimensionNotes:对象,含 hard、experience、evidence、competency、risk 五个键,每个键 1-2 句话说明该维度得分理由或待确认点。\n"
"评估铁律:候选人在简历“自我评价 / 个人优势”中的主观表述(如“抗压能力强”)不作为亮点或比对证据,"
"只能作为面试待验证线索;证据只能来自工作/项目/实习的职责、产出与数据、教育背景、证书与明确技能。\n"
f"综合得分:{score};基础结论:{base.get('decision')};风险摘要:{risk_summary}\n"
f"岗位要求:{criteria_json}\n"
f"岗位信息:{job_json}\n"
f"脱敏候选人:{candidate_json}\n"
f"脱敏简历正文:\n{resume_text}"
)
parsed = await _chat_json(
settings,
"你是企业招聘系统中的资深 HR 报告撰写助手。只返回 JSON;结论必须与给定综合得分一致,不得推翻分数或虚构简历证据。",
user,
temperature=0,
)
if not isinstance(parsed, dict):
return None
parsed.setdefault("dimensionNotes", {})
parsed.setdefault("jdCompare", [])
parsed.setdefault("highlights", [])
parsed["version"] = 2
parsed["enrichProvider"] = f"llm:{settings.effective_llm_model}"
parsed["targetJob"] = str(safe_job.get("title") or "")
return parsed
async def generate_v2_analysis(candidate_data: dict[str, Any], job_data: dict[str, Any]) -> dict[str, Any]:
"""生成 v2 富报告对象 R(基础评估 + 富报告字段)。"""
payload = {"candidate": candidate_data, "job": job_data}
base = await analyze_resume(payload)
enriched = await llm_v2_fields(payload, base)
if enriched is None:
enriched = local_v2_fields(base, job_data, job_criteria(job_data))
result = {**base, **enriched}
result.setdefault("grade", grade_for_score(base.get("score")))
result.setdefault("conclusion", f"{base.get('decision')}({base.get('score')} 分)。")
return result
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
- 关闭模式(auth_reports_client):管理端点工作区隔离;公网只读匿名可访问。 - 关闭模式(auth_reports_client):管理端点工作区隔离;公网只读匿名可访问。
""" """
import json
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
import pytest import pytest
...@@ -197,6 +198,100 @@ def test_public_read_wrong_token_or_unknown(share_open_client): ...@@ -197,6 +198,100 @@ def test_public_read_wrong_token_or_unknown(share_open_client):
assert body["ok"] is False and body["reason"] == "not_found" assert body["ok"] is False and body["reason"] == "not_found"
# ---- Phase B v2:富报告 SSE 生成 + analysis 覆盖冻结 ----
def _v2_state():
job = {
"id": "job-v2-1",
"title": "数据分析师",
"department": "数据部",
"jd": "负责业务数据体系与经营分析,输出可执行结论。",
"mustHave": "本科及以上\n3年以上数据分析经验",
"niceToHave": "SQL\nPython",
"knockout": "无数据项目经历",
"competency": "业务理解\n沟通协同",
"matchKeywords": "SQL,Python,数据分析",
}
candidate = {
"id": "cand-v2-1",
"jobId": "job-v2-1",
"jobTitle": "数据分析师",
"name": "李四",
"source": "Boss",
"stage": "初筛通过",
"education": "本科",
"school": "某大学",
"major": "统计学",
"years": "3年",
"skills": ["SQL", "Python"],
"resumeText": "三年数据分析经验,熟悉 SQL、Python,主导过订单分析项目,将报表效率提升 40%,服务过电商客户。",
}
return {"jobs": [job], "candidates": [candidate], "offers": [], "tasks": [], "eventLog": []}
def _sse_events(text):
events = []
for line in str(text).splitlines():
line = line.strip()
if line.startswith("data:"):
try:
events.append(json.loads(line[5:].strip()))
except json.JSONDecodeError:
continue
return events
def test_v2_stream_local_generation(share_open_client):
assert share_open_client.put("/api/state", json=_v2_state()).status_code == 200
response = share_open_client.post("/api/reports/generate-v2/stream", json={"candidateId": "cand-v2-1"})
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
events = _sse_events(response.text)
done = [event for event in events if event.get("type") == "done"]
assert len(done) == 1
analysis = done[0]["analysis"]
assert analysis.get("version") == 2
assert analysis.get("grade") in ("A", "B", "C", "D")
assert analysis.get("score") is not None
assert isinstance(analysis.get("jdCompare"), list) and analysis["jdCompare"]
assert analysis.get("conclusion")
def test_v2_stream_missing_job_error(share_open_client):
state = _v2_state()
state["candidates"][0]["jobId"] = ""
state["jobs"] = []
assert share_open_client.put("/api/state", json=state).status_code == 200
response = share_open_client.post("/api/reports/generate-v2/stream", json={"candidateId": "cand-v2-1"})
events = _sse_events(response.text)
errors = [event for event in events if event.get("type") == "error"]
assert errors and "关联岗位" in errors[0]["message"]
def test_create_report_with_analysis_override_v2(share_open_client):
assert share_open_client.put("/api/state", json=_v2_state()).status_code == 200
analysis = {
"version": 2,
"score": 82,
"decision": "建议复核后进入初筛",
"conclusion": "总体匹配,需复核数据分析深度。",
"grade": "B",
"highlights": ["主导订单分析项目,报表效率提升 40%"],
"jdCompare": [{"requirement": "本科及以上", "evidence": "统招本科", "conclusion": "匹配"}],
"dimensionNotes": {"hard": "学历达标", "experience": "3 年经验", "evidence": "有项目证据", "competency": "待面试观察", "risk": "无明显风险"},
}
created = share_open_client.post(
"/api/reports", json={"candidateId": "cand-v2-1", "analysis": analysis}
)
assert created.status_code == 200
report = created.json()["report"]
assert report["snapshot"]["analysis"]["version"] == 2
assert report["snapshot"]["analysis"]["grade"] == "B"
# 「仅快照」不改写候选行 analysis
state = share_open_client.get("/api/state").json()
assert "analysis" not in state["candidates"][0]
# ---- SPA 直链回退(公网分享页深链需返回 index.html)---- # ---- SPA 直链回退(公网分享页深链需返回 index.html)----
def test_spa_deep_link_fallback_serves_index(client): def test_spa_deep_link_fallback_serves_index(client):
......
...@@ -26,3 +26,19 @@ ...@@ -26,3 +26,19 @@
- 正向:分享产物与实时评估解耦,符合「导出时刻所见即所得」;不破坏整包 state 机制;匿名面小(只读 + 令牌 + 白名单 + 默认关)。 - 正向:分享产物与实时评估解耦,符合「导出时刻所见即所得」;不破坏整包 state 机制;匿名面小(只读 + 令牌 + 白名单 + 默认关)。
- 代价/注意:报告独立存储 → **整包「导出备份」不含报告**,需要时后续补报告专用导出/迁移;快照内容在生成后不随候选人资料更新(这是刻意语义,分享页应显示「报告生成时间」);公开接口需在 `security.py` 放行名单中精确按前缀处理,避免误放其它 `/api/share` 业务。 - 代价/注意:报告独立存储 → **整包「导出备份」不含报告**,需要时后续补报告专用导出/迁移;快照内容在生成后不随候选人资料更新(这是刻意语义,分享页应显示「报告生成时间」);公开接口需在 `security.py` 放行名单中精确按前缀处理,避免误放其它 `/api/share` 业务。
- 后续可做:Phase B 更丰富的 AI 报告模板(一句话结论 + 维度/亮点/风险 + JD 逐条比对表,同 AI秒聘);PDF(水印)导出;自动过期;人才池报告;报告备份/迁移。 - 后续可做:Phase B 更丰富的 AI 报告模板(一句话结论 + 维度/亮点/风险 + JD 逐条比对表,同 AI秒聘);PDF(水印)导出;自动过期;人才池报告;报告备份/迁移。
## Phase B(v2)追加决策(2026-09-03,已实现 P0+P1)
- 状态:已接受
- **v2 生成方式**:先生成 v1 兼容基础评估(复用 `analyze_resume` 的 LLM → local-structured 回退链),
再做一次「富化」LLM 调用产出 conclusion/grade/highlights/jdCompare/dimensionNotes;无 Key 时用本地规则组装,
绝不 500。输出**单一富对象 R** = 基础评估 + v2 字段 + `version:2`(保留 v1 兼容键,抽屉「评估」页可继续渲染)。
- **SSE 流式**:新增 `POST /api/reports/generate-v2/stream`(stage/done/error),沿用 JD 生成的 SSE 模式,不引入任务队列。
- **维度评分**:沿用现有 scoreBreakdown 五维(硬性/经验/项目证据/胜任力/风险),不引入岗位自定义权重维度。
- **预览与写回(决策 5)**:v2 生成先给预览;「仅生成分享报告」= `POST /api/reports` 携带 analysis 覆盖冻结快照
(不改候选行);「同时刷新 AI 评估」= 先写回 `candidate.analysis`(补 jobSnapshot/inputKey/generatedAt)→ persist →
再按 v1 语义从候选行冻结。两种都产出带 report_id 的分享链接,旧 v1 快照/旧分析兼容渲染。
- **PDF(P1,轻量)**:不做服务端渲染器;公网页「打印 / 存 PDF」+ 每页固定轻量水印(报告编号/内部资料/生成人),
浏览器另存 PDF 即得水印版。
- 边界更新:v2 仍限个人工作区简历库抽屉;人才池报告、自动过期(表字段已预留)、报告备份/迁移仍为后续项。
...@@ -179,12 +179,64 @@ export async function listCandidateReports(candidateId) { ...@@ -179,12 +179,64 @@ export async function listCandidateReports(candidateId) {
return data return data
} }
export async function createCandidateReport(candidateId) { export async function createCandidateReport(candidateId, analysis = null) {
const { data } = await http.post('/api/reports', { candidateId }) const body = { candidateId }
if (analysis && typeof analysis === 'object') body.analysis = analysis
const { data } = await http.post('/api/reports', body)
if (data.error) throw new Error(data.error) if (data.error) throw new Error(data.error)
return data return data
} }
// v2 富报告 SSE 流式生成(Phase B):stage / done / error 事件。
export async function generateV2ReportStream(candidateId, handlers = {}) {
const { onStage, onDone, onError } = handlers
let lastError = ''
let base = ''
if (typeof window !== 'undefined' && window.RECRUITMENT_API_BASE_URL) {
base = String(window.RECRUITMENT_API_BASE_URL).replace(/\/+$/, '')
}
const token = readToken()
const response = await fetch(`${base}/api/reports/generate-v2/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ candidateId }),
})
if (!response.ok || !response.body) {
throw new Error(`报告生成请求失败(HTTP ${response.status})`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) continue
let event
try {
event = JSON.parse(trimmed.slice(5).trim())
} catch {
continue
}
if (event.type === 'stage' && onStage) onStage(event.stage, event.message || '')
else if (event.type === 'done' && onDone) onDone(event.analysis)
else if (event.type === 'error') {
lastError = event.message || '报告生成失败'
if (onError) onError(lastError)
}
}
}
if (lastError) throw new Error(lastError)
}
export async function setCandidateReportStatus(reportId, status) { export async function setCandidateReportStatus(reportId, status) {
const { data } = await http.patch(`/api/reports/${reportId}`, { status }) const { data } = await http.patch(`/api/reports/${reportId}`, { status })
if (data.error) throw new Error(data.error) if (data.error) throw new Error(data.error)
......
<script setup>
// 候选人分析报告内容渲染(ADR 0002 Phase A/B 通用):
// - v1 快照:得分/结论/摘要/风险/评估明细(与抽屉「评估」同源);
// - v2 富对象:额外渲染等级、一句话结论、维度评分条、核心亮点、JD 逐条比对。
// 数据全部来自后端冻结的 snapshot,页面/抽屉预览共用,避免两套渲染分叉。
import { computed } from 'vue'
import { providerLabel, scoreBreakdownItems } from '@/utils/scoring'
import { assessmentDecision, assessmentReportRows, assessmentScoreText, riskProfileRows } from '@/utils/report'
const props = defineProps({
report: { type: Object, required: true },
})
const snapshot = computed(() => props.report?.snapshot || {})
const analysis = computed(() => snapshot.value.analysis || {})
const isV2 = computed(() => analysis.value.version === 2)
const candidate = computed(() => ({
id: snapshot.value.candidateId,
name: snapshot.value.candidateName,
jobTitle: snapshot.value.jobTitle,
analysis: analysis.value,
matchReport: snapshot.value.matchReport,
quickMatchScore: snapshot.value.quickMatchScore,
}))
const scoreText = computed(() => assessmentScoreText(candidate.value, 'ai'))
const decisionInfo = computed(() => assessmentDecision(candidate.value, 'ai'))
const reportRows = computed(() => assessmentReportRows(candidate.value, 'ai'))
const riskProfile = computed(() => riskProfileRows(analysis.value))
const dimensionItems = computed(() => scoreBreakdownItems(analysis.value))
const author = computed(() => snapshot.value.author || {})
const gradeTone = computed(() => {
const grade = String(analysis.value.grade || '').toUpperCase()
if (grade === 'A') return 'success'
if (grade === 'B') return 'primary'
if (grade === 'C') return 'warning'
return 'danger'
})
const jdConclusionMeta = (conclusion = '') => {
const map = {
匹配: { type: 'success', text: '匹配' },
部分匹配: { type: 'primary', text: '部分匹配' },
未体现: { type: 'danger', text: '未体现' },
待确认: { type: 'warning', text: '待确认' },
}
return map[conclusion] || { type: 'info', text: conclusion || '待确认' }
}
const toneTag = (level) =>
({
ok: { type: 'success', text: '正常' },
hint: { type: 'warning', text: '提示' },
warn: { type: 'danger', text: '需确认' },
})[level] || { type: 'info', text: level }
const DIM_NOTES_KEY = ['hard', 'experience', 'evidence', 'competency', 'risk']
const dimensionNote = (index) => {
const notes = analysis.value.dimensionNotes || {}
const key = DIM_NOTES_KEY[index]
return key ? notes[key] : ''
}
const toText = (item) => {
if (item && typeof item === 'object') {
if (item.title && item.detail) return `${item.title}${item.detail}`
return item.title || item.text || item.detail || JSON.stringify(item)
}
return String(item ?? '')
}
const formatTime = (iso = '') => {
if (!iso) return ''
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
</script>
<template>
<div class="report-content">
<section class="candidate-head">
<div class="name-row">
<h1>{{ snapshot.candidateName || '未命名候选人' }}</h1>
<el-tag size="small" effect="plain">{{ snapshot.jobTitle || '待匹配岗位' }}</el-tag>
<el-tag v-if="snapshot.source" size="small" type="info" effect="plain">{{ snapshot.source }}</el-tag>
<el-tag v-if="isV2" size="small" :type="gradeTone" effect="dark" class="grade-tag">
等级 {{ analysis.grade }}
</el-tag>
<el-tag v-if="isV2" size="small" type="primary" effect="plain">v2 富评估</el-tag>
</div>
<div class="meta-row">
<span>报告编号:{{ report.reportId || '—' }}</span>
<span>生成时间:{{ formatTime(report.generatedAt) }}</span>
<span>评估方式:{{ providerLabel(analysis.provider || '') }}</span>
<span>生成人:{{ author.name || author.username || '—' }}</span>
</div>
</section>
<section class="summary-strip" :class="`tone-${decisionInfo.tone}`">
<div class="score-block">
<strong class="score-value">{{ scoreText }}</strong>
<span>综合得分</span>
</div>
<div class="decision-block">
<span class="eyebrow">初筛建议</span>
<strong>{{ decisionInfo.text }}</strong>
<p>{{ decisionInfo.note }}</p>
</div>
</section>
<section v-if="isV2 && analysis.conclusion" class="block">
<h4>一句话结论</h4>
<p class="conclusion-text">{{ analysis.conclusion }}</p>
</section>
<section v-if="analysis.summary" class="block">
<h4>评估摘要</h4>
<p class="summary-text">{{ analysis.summary }}</p>
</section>
<section v-if="isV2 && dimensionItems.length" class="block">
<h4>维度评分</h4>
<div class="dimension-list">
<div
v-for="(item, index) in dimensionItems"
:key="item.label"
class="dimension-item"
:class="{ total: item.total }"
>
<div class="dimension-head">
<b>{{ item.label }}</b>
<strong>{{ item.value < 0 ? item.value : `${item.value}/${item.max}` }}</strong>
</div>
<div class="dimension-track">
<i
:style="{
width: item.total
? `${Math.min(100, Math.max(0, item.value))}%`
: `${Math.min(100, (Math.abs(item.value) / item.max) * 100)}%`,
}"
/>
</div>
<p class="dimension-hint">{{ item.hint }}</p>
<p v-if="dimensionNote(index)" class="dimension-note">{{ dimensionNote(index) }}</p>
</div>
</div>
</section>
<section v-if="isV2 && analysis.highlights && analysis.highlights.length" class="block">
<h4>核心亮点</h4>
<ul class="highlight-list">
<li v-for="(item, index) in analysis.highlights" :key="index">{{ toText(item) }}</li>
</ul>
</section>
<section v-if="isV2 && analysis.jdCompare && analysis.jdCompare.length" class="block">
<h4>JD 逐条比对</h4>
<div class="jd-compare">
<div v-for="(row, index) in analysis.jdCompare" :key="index" class="jd-row">
<div class="jd-requirement">
<span>岗位要求</span>
<p>{{ toText(row.requirement) }}</p>
</div>
<div class="jd-evidence">
<span>候选人证据</span>
<p>{{ toText(row.evidence) || '未在简历中找到明确证据' }}</p>
</div>
<el-tag :type="jdConclusionMeta(row.conclusion).type" size="small" effect="light">
{{ jdConclusionMeta(row.conclusion).text }}
</el-tag>
</div>
</div>
</section>
<section v-if="riskProfile.length" class="block">
<h4>风险点评估</h4>
<div class="risk-list">
<div v-for="item in riskProfile" :key="item.dimension" class="risk-item" :class="`level-${item.level}`">
<el-tag :type="toneTag(item.level).type" size="small" effect="light">
{{ toneTag(item.level).text }}
</el-tag>
<b>{{ item.label }}</b>
<p>{{ item.summary }}</p>
<span v-for="(ev, index) in item.evidence || []" :key="`${item.dimension}-${index}`"> 依据:{{ ev }} </span>
</div>
</div>
</section>
<section v-if="reportRows.length" class="block">
<h4>评估明细</h4>
<div v-for="row in reportRows" :key="row.label" class="detail-card" :class="`tone-${row.tone}`">
<div class="detail-head">
<b>{{ row.label }}</b>
<el-tag :type="row.tone" size="small" effect="light">{{ row.status }}</el-tag>
</div>
<ul v-if="row.items.length">
<li v-for="(item, index) in row.items" :key="index">{{ item }}</li>
</ul>
<p v-else class="detail-empty">暂无</p>
</div>
</section>
<footer class="report-footer">
<p>本报告由 AI 辅助生成,基于生成时刻的简历与岗位信息冻结,仅供内部初筛参考,请以人工复核与面试为准。</p>
<p>不含候选人联系方式与原简历文件。</p>
</footer>
</div>
</template>
<style lang="scss" scoped>
.report-content {
width: 100%;
}
.candidate-head {
padding: 4px 0 14px;
.name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
h1 {
margin: 0;
font-size: 24px;
}
.grade-tag {
font-weight: 700;
}
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
margin-top: 10px;
font-size: 12px;
color: var(--muted);
}
}
.summary-strip {
display: flex;
align-items: stretch;
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
margin-bottom: 16px;
.score-block {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 168px;
padding: 16px 18px;
background: rgba(255, 255, 255, 0.8);
.score-value {
font-size: 30px;
line-height: 1;
}
span {
font-size: 12px;
color: var(--muted);
}
}
.decision-block {
flex: 1;
min-width: 0;
padding: 14px 18px;
display: flex;
flex-direction: column;
justify-content: center;
border-left: 1px solid var(--line);
.eyebrow {
font-size: 11px;
letter-spacing: 1px;
color: var(--muted);
}
strong {
margin: 4px 0 2px;
font-size: 17px;
}
p {
margin: 0;
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
}
&.tone-success {
border-color: color-mix(in srgb, var(--green) 55%, var(--line));
.score-value,
.decision-block strong {
color: var(--green);
}
}
&.tone-warning {
border-color: color-mix(in srgb, var(--yellow) 55%, var(--line));
.score-value,
.decision-block strong {
color: var(--yellow);
}
}
&.tone-danger {
border-color: color-mix(in srgb, var(--rose) 55%, var(--line));
.score-value,
.decision-block strong {
color: var(--rose);
}
}
&.tone-info {
.score-value,
.decision-block strong {
color: var(--blue);
}
}
}
.block {
margin-bottom: 16px;
h4 {
margin: 0 0 8px;
font-size: 14px;
}
.conclusion-text {
margin: 0;
padding: 12px 14px;
border: 1px solid var(--line);
border-left: 3px solid var(--blue);
border-radius: 10px;
background: color-mix(in srgb, var(--blue) 6%, #fff);
font-size: 14px;
line-height: 1.7;
color: var(--ink);
font-weight: 500;
}
.summary-text {
margin: 0;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.7);
font-size: 13px;
line-height: 1.7;
color: var(--ink);
}
}
.dimension-list {
display: flex;
flex-direction: column;
gap: 8px;
.dimension-item {
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.7);
&.total {
border-color: color-mix(in srgb, var(--blue) 45%, var(--line));
background: color-mix(in srgb, var(--blue) 5%, #fff);
}
.dimension-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
b {
font-size: 13px;
}
strong {
font-size: 13px;
}
}
.dimension-track {
margin: 6px 0 4px;
height: 6px;
border-radius: 999px;
background: var(--line);
overflow: hidden;
i {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--blue), var(--cyan));
}
}
.dimension-hint,
.dimension-note {
margin: 0;
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
.dimension-note {
margin-top: 2px;
color: var(--ink);
}
}
}
.highlight-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
li {
padding: 8px 12px;
border: 1px solid var(--line);
border-left: 3px solid var(--green);
border-radius: 8px;
background: color-mix(in srgb, var(--green) 6%, #fff);
font-size: 13px;
line-height: 1.6;
}
}
.jd-compare {
display: flex;
flex-direction: column;
gap: 8px;
.jd-row {
display: grid;
grid-template-columns: 30% 1fr auto;
gap: 12px;
align-items: start;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.7);
.jd-requirement,
.jd-evidence {
min-width: 0;
span {
font-size: 11px;
color: var(--muted);
}
p {
margin: 3px 0 0;
font-size: 13px;
line-height: 1.55;
color: var(--ink);
word-break: break-word;
}
}
}
}
.risk-list {
display: flex;
flex-direction: column;
gap: 8px;
.risk-item {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: center;
gap: 4px 10px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.6);
b {
font-size: 13px;
}
p {
grid-column: 1 / -1;
margin: 2px 0 0;
font-size: 13px;
line-height: 1.6;
}
span {
grid-column: 1 / -1;
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
&.level-warn {
border-color: color-mix(in srgb, var(--rose) 45%, var(--line));
background: color-mix(in srgb, var(--rose) 6%, #fff);
}
&.level-hint {
border-color: color-mix(in srgb, var(--yellow) 45%, var(--line));
background: color-mix(in srgb, var(--yellow) 7%, #fff);
}
}
}
.detail-card {
margin-bottom: 10px;
padding: 12px 14px;
border: 1px solid var(--line);
border-left-width: 3px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.7);
&.tone-success {
border-left-color: var(--green);
}
&.tone-warning {
border-left-color: var(--yellow);
}
&.tone-danger {
border-left-color: var(--rose);
}
&.tone-info {
border-left-color: var(--blue);
}
.detail-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
b {
font-size: 14px;
}
}
ul {
margin: 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 5px;
li {
font-size: 13px;
line-height: 1.6;
color: var(--ink);
}
}
.detail-empty {
margin: 0;
font-size: 13px;
color: var(--muted);
}
}
.report-footer {
margin-top: 18px;
padding-top: 12px;
border-top: 1px dashed var(--line);
font-size: 12px;
color: var(--muted);
line-height: 1.6;
p {
margin: 2px 0;
}
}
@media (max-width: 620px) {
.summary-strip {
flex-direction: column;
.score-block {
min-width: 0;
padding: 14px;
}
.decision-block {
border-left: 0;
border-top: 1px solid var(--line);
}
}
.jd-compare .jd-row {
grid-template-columns: 1fr auto;
}
}
</style>
...@@ -6,17 +6,19 @@ import { ElMessageBox } from 'element-plus' ...@@ -6,17 +6,19 @@ import { ElMessageBox } from 'element-plus'
import http from '@/api' import http from '@/api'
import * as reportApi from '@/api/recruitment' import * as reportApi from '@/api/recruitment'
import ReportContentView from '@/components/reports/ReportContentView.vue'
import { useRecruitmentStore } from '@/stores/recruitment' import { useRecruitmentStore } from '@/stores/recruitment'
import { CANDIDATE_TAG_OPTIONS, candidateTagTone } from '@/utils/constants' import { CANDIDATE_TAG_OPTIONS, candidateTagTone } from '@/utils/constants'
import { candidateWorkflow, workflowStageClass } from '@/utils/task' import { candidateWorkflow, workflowStageClass } from '@/utils/task'
import { candidateScreeningConclusion, quickScore } from '@/utils/resume' import { candidateScreeningConclusion, quickScore } from '@/utils/resume'
import { isAiEvaluationReport } from '@/utils/scoring' import { isAiEvaluationReport } from '@/utils/scoring'
import { analysisNeedsRefresh } from '@/utils/analysis' import { analysisInputKey, analysisNeedsRefresh } from '@/utils/analysis'
import { resumeDisplayName, candidateAge, candidateYears } from '@/utils/candidate' import { resumeDisplayName, candidateAge, candidateYears } from '@/utils/candidate'
import { uid } from '@/utils/normalize' import { uid } from '@/utils/normalize'
import { assessmentDecision, assessmentReportRows, assessmentScoreText, riskProfileRows } from '@/utils/report' import { assessmentDecision, assessmentReportRows, assessmentScoreText, riskProfileRows } from '@/utils/report'
import { getFilteredCandidates } from '@/utils/matching' import { getFilteredCandidates } from '@/utils/matching'
import { jobCandidates } from '@/utils/links' import { buildJobSnapshot } from '@/utils/job'
import { jobCandidates, matchedJobForCandidate } from '@/utils/links'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
const store = useRecruitmentStore() const store = useRecruitmentStore()
...@@ -321,6 +323,10 @@ const reportListLoading = ref(false) ...@@ -321,6 +323,10 @@ const reportListLoading = ref(false)
const reportList = ref([]) const reportList = ref([])
const reportGenerating = ref(false) const reportGenerating = ref(false)
const reportShareEnabled = ref(true) const reportShareEnabled = ref(true)
const reportGeneratingStage = ref('')
const reportPreviewOpen = ref(false)
const reportPreview = ref(null)
const reportCommitting = ref(false)
const formatReportTime = (iso = '') => { const formatReportTime = (iso = '') => {
if (!iso) return '' if (!iso) return ''
...@@ -378,30 +384,76 @@ const generateReport = async () => { ...@@ -378,30 +384,76 @@ const generateReport = async () => {
return return
} }
reportGenerating.value = true reportGenerating.value = true
reportGeneratingStage.value = '准备中…'
try { try {
if (!store.localMatch(candidate.id)) throw new Error('请先为候选人选择并确认关联岗位') if (!store.localMatch(candidate.id)) throw new Error('请先为候选人选择并确认关联岗位')
const previous = candidate.analysis await store.saveStateToServer()
const reusable = isAiEvaluationReport(previous) && !analysisNeedsRefresh(candidate) let analysis = null
if (!reusable) { await reportApi.generateV2ReportStream(candidate.id, {
showToast('正在生成 AI 评估报告...', 'info') onStage: (_stage, message) => {
candidate.analysis = await store.analyzeResume(candidate) reportGeneratingStage.value = message || 'AI 正在生成报告…'
},
onDone: (value) => {
analysis = value
},
})
if (!analysis) throw new Error('报告生成失败,请稍后重试')
reportPreview.value = {
reportId: '预览',
generatedAt: new Date().toISOString(),
snapshot: {
candidateId: candidate.id,
candidateName: candidate.name,
jobTitle: candidate.jobTitle,
source: candidate.source,
analysis,
},
}
reportPreviewOpen.value = true
} catch (error) {
showToast(error.message || '分析报告生成失败', 'error')
} finally {
reportGenerating.value = false
reportGeneratingStage.value = ''
}
}
// 预览确认(决策 5):仅冻结快照(不写回),或先刷新 candidate.analysis 再冻结。
const commitPreview = async (writeBack = false) => {
const candidate = selected.value
const analysis = reportPreview.value?.snapshot?.analysis
if (!candidate || !analysis || reportCommitting.value) return
reportCommitting.value = true
try {
if (writeBack) {
const job = matchedJobForCandidate(candidate, store.jobs) || {}
const jobSnapshot = buildJobSnapshot(job)
analysis.jobSnapshot = jobSnapshot
analysis.inputKey = analysisInputKey(candidate, jobSnapshot)
analysis.generatedAt = new Date().toISOString()
candidate.analysis = analysis
store.resumeDetailMode = 'ai' store.resumeDetailMode = 'ai'
await store.saveStateToServer()
} }
await store.saveStateToServer() const payload = writeBack
const payload = await reportApi.createCandidateReport(candidate.id) ? await reportApi.createCandidateReport(candidate.id)
: await reportApi.createCandidateReport(candidate.id, analysis)
const report = payload.report const report = payload.report
store.logEvent('report_generated', candidate, { store.logEvent('report_generated', candidate, {
reportId: report.reportId, reportId: report.reportId,
score: reportSnapshotScore(report.snapshot), score: reportSnapshotScore(report.snapshot),
reportVersion: report.snapshot?.analysis?.version === 2 ? 'v2' : 'v1',
}) })
store.persist('分析报告已生成') store.persist('分析报告已生成')
reportPreviewOpen.value = false
reportPreview.value = null
await copyText(report.shareUrl) await copyText(report.shareUrl)
showToast('分析报告已生成,链接已复制到剪贴板', 'success') showToast('分析报告已生成,链接已复制到剪贴板', 'success')
await loadReports() await loadReports()
} catch (error) { } catch (error) {
showToast(error.message || '分析报告生成失败', 'error') showToast(error.message || '报告创建失败', 'error')
} finally { } finally {
reportGenerating.value = false reportCommitting.value = false
} }
} }
...@@ -1085,7 +1137,7 @@ const saveInterview = (candidate) => { ...@@ -1085,7 +1137,7 @@ const saveInterview = (candidate) => {
</footer> </footer>
</div> </div>
<el-dialog v-model="reportDialogOpen" title="分析报告" width="620px" append-to-body class="report-panel-dialog"> <el-dialog v-model="reportDialogOpen" :close-on-click-modal="false" title="分析报告" width="620px" append-to-body class="report-panel-dialog">
<div class="report-panel"> <div class="report-panel">
<div class="report-panel-head"> <div class="report-panel-head">
<div class="report-panel-identity"> <div class="report-panel-identity">
...@@ -1093,7 +1145,7 @@ const saveInterview = (candidate) => { ...@@ -1093,7 +1145,7 @@ const saveInterview = (candidate) => {
<span>{{ selected?.jobTitle || '待匹配岗位' }}</span> <span>{{ selected?.jobTitle || '待匹配岗位' }}</span>
</div> </div>
<el-button type="primary" :loading="reportGenerating" :disabled="!reportShareEnabled" @click="generateReport"> <el-button type="primary" :loading="reportGenerating" :disabled="!reportShareEnabled" @click="generateReport">
生成报告 生成 v2 报告
</el-button> </el-button>
</div> </div>
<el-alert <el-alert
...@@ -1106,8 +1158,12 @@ const saveInterview = (candidate) => { ...@@ -1106,8 +1158,12 @@ const saveInterview = (candidate) => {
description="当前部署未开启 REPORT_SHARE_ENABLED,生成的链接外部无法访问;请联系管理员在配置中开启。" description="当前部署未开启 REPORT_SHARE_ENABLED,生成的链接外部无法访问;请联系管理员在配置中开启。"
/> />
<p class="report-hint"> <p class="report-hint">
每次「生成新报告」会基于当前 AI 每次「生成 v2 报告」会先生成富报告并弹窗预览,可在「仅生成分享报告」与「同时刷新 AI
评估冻结一份快照并创建公网链接;分享页仅含姓名、岗位与评估结论,不含联系方式与原简历。 评估」之间选择;确认后才会冻结快照并创建公网链接。分享页仅含姓名、岗位与评估结论,不含联系方式与原简历。
</p>
<p v-if="reportGenerating" class="report-stage">
<el-icon class="is-loading"><Loading /></el-icon>
{{ reportGeneratingStage }}
</p> </p>
<div v-if="reportListLoading" class="report-list-loading">加载中…</div> <div v-if="reportListLoading" class="report-list-loading">加载中…</div>
<div v-else-if="!reportList.length" class="report-empty">还没有分析报告,点击右上角「生成新报告」创建。</div> <div v-else-if="!reportList.length" class="report-empty">还没有分析报告,点击右上角「生成新报告」创建。</div>
...@@ -1157,6 +1213,33 @@ const saveInterview = (candidate) => { ...@@ -1157,6 +1213,33 @@ const saveInterview = (candidate) => {
</div> </div>
</div> </div>
</el-dialog> </el-dialog>
<el-dialog
v-model="reportPreviewOpen"
title="报告预览(v2 富评估)"
width="820px"
top="6vh"
append-to-body
class="report-preview-dialog"
>
<div class="report-preview-body">
<ReportContentView v-if="reportPreview" :report="reportPreview" />
</div>
<template #footer>
<div class="preview-footer">
<span class="preview-tip">
预览尚未保存。「同时刷新 AI 评估并分享」会把该富报告写回候选人评估(抽屉「评估」页同步变丰富)后再生成链接。
</span>
<div class="preview-actions">
<el-button :disabled="reportCommitting" @click="reportPreviewOpen = false">取消</el-button>
<el-button :loading="reportCommitting" @click="commitPreview(false)">仅生成分享报告</el-button>
<el-button type="primary" :loading="reportCommitting" @click="commitPreview(true)">
同时刷新 AI 评估并分享
</el-button>
</div>
</div>
</template>
</el-dialog>
</el-drawer> </el-drawer>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
...@@ -2173,6 +2256,47 @@ const saveInterview = (candidate) => { ...@@ -2173,6 +2256,47 @@ const saveInterview = (candidate) => {
} }
} }
} }
// 报告生成进度与 v2 预览(Phase B)
.report-stage {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 10px;
padding: 8px 10px;
border-radius: 8px;
background: color-mix(in srgb, var(--blue) 6%, #fff);
color: var(--ink);
font-size: 12px;
}
.report-preview-dialog {
.report-preview-body {
max-height: 62vh;
overflow: auto;
padding: 2px 4px;
}
.preview-footer {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
.preview-tip {
font-size: 12px;
line-height: 1.6;
color: var(--muted);
}
.preview-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
}
}
// 分析报告面板(ADR 0002) // 分析报告面板(ADR 0002)
.report-panel { .report-panel {
.report-panel-head { .report-panel-head {
......
// 把评估字段里的条目统一转成可读文本:LLM 可能返回对象(如风险
// {type,finding,basis})或 JSON 字符串,直接插值会显示成原始 JSON。
function plainText(item) {
if (item && typeof item === 'object') {
const type = item.type || item.label || item.dimension || ''
const finding = item.finding || item.summary || item.text || item.detail || ''
const basis = item.basis || item.evidence || ''
let text = String(finding || '').trim()
const basisText = String(basis || '').trim()
if (basisText) text = text ? `${text}(依据:${basisText})` : `依据:${basisText}`
if (type) text = text ? `【${type}${text}` : `【${type}】`
return text || JSON.stringify(item)
}
const value = String(item ?? '').trim()
if (value.startsWith('{')) {
try {
const parsed = JSON.parse(value)
if (parsed && typeof parsed === 'object') return plainText(parsed)
} catch {
// 保持原文
}
}
return value
}
function listItems(value) { function listItems(value) {
return Array.isArray(value) ? value.filter((item) => String(item || '').trim()) : [] return Array.isArray(value) ? value.map(plainText).filter((item) => String(item || '').trim()) : []
} }
// 需求 17:风险固定维度(跳槽频率 / 竞业协议 / 职业空窗 / 高层职业道德) // 需求 17:风险固定维度(跳槽频率 / 竞业协议 / 职业空窗 / 高层职业道德)
......
<script setup> <script setup>
// 分析报告公网分享页(ADR 0002):免登录只读展示候选人 AI 初筛分析快照。 // 分析报告公网分享页(ADR 0002):免登录只读展示候选人分析报告快照。
// 数据来自后端匿名只读接口 /api/share/report/{reportId}?token=...; // 内容渲染交给 ReportContentView(v1/v2 通用);本页负责外壳、加载/失效态与打印水印(P1 轻量 PDF)。
// 渲染逻辑复用 utils/report.js(与抽屉「评估」同源),快照不含电话/邮箱/原简历。
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { fetchSharedReport } from '@/api/recruitment' import { fetchSharedReport } from '@/api/recruitment'
import { providerLabel } from '@/utils/scoring' import ReportContentView from '@/components/reports/ReportContentView.vue'
import { assessmentDecision, assessmentReportRows, assessmentScoreText, riskProfileRows } from '@/utils/report'
const route = useRoute() const route = useRoute()
...@@ -15,37 +13,15 @@ const loading = ref(true) ...@@ -15,37 +13,15 @@ const loading = ref(true)
const errorReason = ref('') // '' | 'disabled' | 'not_found' const errorReason = ref('') // '' | 'disabled' | 'not_found'
const report = ref(null) const report = ref(null)
const snapshot = computed(() => report.value?.snapshot || {}) const author = computed(() => report.value?.snapshot?.author || {})
const candidate = computed(() => ({ const generatedDate = computed(() => {
id: snapshot.value.candidateId, const iso = report.value?.generatedAt || ''
name: snapshot.value.candidateName,
jobTitle: snapshot.value.jobTitle,
analysis: snapshot.value.analysis,
matchReport: snapshot.value.matchReport,
quickMatchScore: snapshot.value.quickMatchScore,
}))
const scoreText = computed(() => (report.value ? assessmentScoreText(candidate.value, 'ai') : ''))
const decisionInfo = computed(() => (report.value ? assessmentDecision(candidate.value, 'ai') : {}))
const reportRows = computed(() => (report.value ? assessmentReportRows(candidate.value, 'ai') : []))
const riskProfile = computed(() => riskProfileRows(snapshot.value.analysis || {}))
const author = computed(() => snapshot.value.author || {})
const provider = computed(() => providerLabel(snapshot.value.analysis?.provider || ''))
const toneTag = (level) =>
({
ok: { type: 'success', text: '正常' },
hint: { type: 'warning', text: '提示' },
warn: { type: 'danger', text: '需确认' },
})[level] || { type: 'info', text: level }
const formatTime = (iso = '') => {
if (!iso) return '' if (!iso) return ''
const date = new Date(iso) const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso if (Number.isNaN(date.getTime())) return iso
const pad = (n) => String(n).padStart(2, '0') const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
} })
const printPage = () => window.print() const printPage = () => window.print()
...@@ -78,7 +54,7 @@ onMounted(async () => { ...@@ -78,7 +54,7 @@ onMounted(async () => {
<span>候选人 AI 初筛分析报告</span> <span>候选人 AI 初筛分析报告</span>
</div> </div>
</div> </div>
<el-button size="small" class="print-btn no-print" @click="printPage"> <el-button v-if="report" size="small" class="print-btn no-print" @click="printPage">
<el-icon><Printer /></el-icon> <el-icon><Printer /></el-icon>
打印 / 存 PDF 打印 / 存 PDF
</el-button> </el-button>
...@@ -97,69 +73,12 @@ onMounted(async () => { ...@@ -97,69 +73,12 @@ onMounted(async () => {
</div> </div>
<template v-else-if="report"> <template v-else-if="report">
<section class="candidate-head"> <ReportContentView :report="report" />
<div class="name-row"> <div class="print-watermark no-screen" aria-hidden="true">
<h1>{{ snapshot.candidateName || '未命名候选人' }}</h1> <div>内部资料 · 仅供内部初筛参考</div>
<el-tag size="small" effect="plain">{{ snapshot.jobTitle || '待匹配岗位' }}</el-tag> <div>{{ report.reportId }}</div>
<el-tag v-if="snapshot.source" size="small" type="info" effect="plain">{{ snapshot.source }}</el-tag> <div v-if="author.name || author.username">{{ author.name || author.username }} · {{ generatedDate }}</div>
</div> </div>
<div class="meta-row">
<span>报告编号:{{ report.reportId }}</span>
<span>生成时间:{{ formatTime(report.generatedAt) }}</span>
<span>评估方式:{{ provider }}</span>
<span>生成人:{{ author.name || author.username || '—' }}</span>
</div>
</section>
<section class="summary-strip" :class="`tone-${decisionInfo.tone}`">
<div class="score-block">
<strong class="score-value">{{ scoreText }}</strong>
<span>综合得分</span>
</div>
<div class="decision-block">
<span class="eyebrow">初筛建议</span>
<strong>{{ decisionInfo.text }}</strong>
<p>{{ decisionInfo.note }}</p>
</div>
</section>
<section v-if="snapshot.analysis?.summary" class="block">
<h4>评估摘要</h4>
<p class="summary-text">{{ snapshot.analysis.summary }}</p>
</section>
<section v-if="riskProfile.length" class="block">
<h4>风险点评估</h4>
<div class="risk-list">
<div v-for="item in riskProfile" :key="item.dimension" class="risk-item" :class="`level-${item.level}`">
<el-tag :type="toneTag(item.level).type" size="small" effect="light">
{{ toneTag(item.level).text }}
</el-tag>
<b>{{ item.label }}</b>
<p>{{ item.summary }}</p>
<span v-for="(ev, index) in item.evidence || []" :key="`${item.dimension}-${index}`">依据:{{ ev }}</span>
</div>
</div>
</section>
<section v-if="reportRows.length" class="block">
<h4>评估明细</h4>
<div v-for="row in reportRows" :key="row.label" class="detail-card" :class="`tone-${row.tone}`">
<div class="detail-head">
<b>{{ row.label }}</b>
<el-tag :type="row.tone" size="small" effect="light">{{ row.status }}</el-tag>
</div>
<ul v-if="row.items.length">
<li v-for="(item, index) in row.items" :key="index">{{ item }}</li>
</ul>
<p v-else class="detail-empty">暂无</p>
</div>
</section>
<footer class="report-footer">
<p>本报告由 AI 辅助生成,基于生成时刻的简历与岗位信息冻结,仅供内部初筛参考,请以人工复核与面试为准。</p>
<p>不含候选人联系方式与原简历文件。</p>
</footer>
</template> </template>
</main> </main>
</div> </div>
...@@ -224,6 +143,10 @@ onMounted(async () => { ...@@ -224,6 +143,10 @@ onMounted(async () => {
} }
} }
.no-screen {
display: none;
}
.state-box { .state-box {
padding: 72px 24px; padding: 72px 24px;
text-align: center; text-align: center;
...@@ -255,259 +178,6 @@ onMounted(async () => { ...@@ -255,259 +178,6 @@ onMounted(async () => {
} }
} }
.candidate-head {
padding: 18px 0 14px;
.name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
h1 {
margin: 0;
font-size: 24px;
}
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
margin-top: 10px;
font-size: 12px;
color: var(--muted);
}
}
.summary-strip {
display: flex;
align-items: stretch;
gap: 0;
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
margin-bottom: 16px;
.score-block {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 168px;
padding: 16px 18px;
background: rgba(255, 255, 255, 0.8);
.score-value {
font-size: 30px;
line-height: 1;
}
span {
font-size: 12px;
color: var(--muted);
}
}
.decision-block {
flex: 1;
min-width: 0;
padding: 14px 18px;
display: flex;
flex-direction: column;
justify-content: center;
border-left: 1px solid var(--line);
.eyebrow {
font-size: 11px;
letter-spacing: 1px;
color: var(--muted);
}
strong {
margin: 4px 0 2px;
font-size: 17px;
}
p {
margin: 0;
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
}
&.tone-success {
border-color: color-mix(in srgb, var(--green) 55%, var(--line));
.score-value,
.decision-block strong {
color: var(--green);
}
}
&.tone-warning {
border-color: color-mix(in srgb, var(--yellow) 55%, var(--line));
.score-value,
.decision-block strong {
color: var(--yellow);
}
}
&.tone-danger {
border-color: color-mix(in srgb, var(--rose) 55%, var(--line));
.score-value,
.decision-block strong {
color: var(--rose);
}
}
&.tone-info {
.score-value,
.decision-block strong {
color: var(--blue);
}
}
}
.block {
margin-bottom: 16px;
h4 {
margin: 0 0 8px;
font-size: 14px;
}
.summary-text {
margin: 0;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.7);
font-size: 13px;
line-height: 1.7;
color: var(--ink);
}
}
.risk-list {
display: flex;
flex-direction: column;
gap: 8px;
.risk-item {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: center;
gap: 4px 10px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.6);
b {
font-size: 13px;
}
p {
grid-column: 1 / -1;
margin: 2px 0 0;
font-size: 13px;
line-height: 1.6;
}
span {
grid-column: 1 / -1;
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
&.level-warn {
border-color: color-mix(in srgb, var(--rose) 45%, var(--line));
background: color-mix(in srgb, var(--rose) 6%, #fff);
}
&.level-hint {
border-color: color-mix(in srgb, var(--yellow) 45%, var(--line));
background: color-mix(in srgb, var(--yellow) 7%, #fff);
}
}
}
.detail-card {
margin-bottom: 10px;
padding: 12px 14px;
border: 1px solid var(--line);
border-left-width: 3px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.7);
&.tone-success {
border-left-color: var(--green);
}
&.tone-warning {
border-left-color: var(--yellow);
}
&.tone-danger {
border-left-color: var(--rose);
}
&.tone-info {
border-left-color: var(--blue);
}
.detail-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
b {
font-size: 14px;
}
}
ul {
margin: 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 5px;
li {
font-size: 13px;
line-height: 1.6;
color: var(--ink);
}
}
.detail-empty {
margin: 0;
font-size: 13px;
color: var(--muted);
}
}
.report-footer {
margin-top: 18px;
padding-top: 12px;
border-top: 1px dashed var(--line);
font-size: 12px;
color: var(--muted);
line-height: 1.6;
p {
margin: 2px 0;
}
}
@keyframes pulse { @keyframes pulse {
0%, 0%,
100% { 100% {
...@@ -522,20 +192,6 @@ onMounted(async () => { ...@@ -522,20 +192,6 @@ onMounted(async () => {
} }
@media (max-width: 620px) { @media (max-width: 620px) {
.summary-strip {
flex-direction: column;
.score-block {
min-width: 0;
padding: 14px;
}
.decision-block {
border-left: 0;
border-top: 1px solid var(--line);
}
}
.report-head { .report-head {
align-items: flex-start; align-items: flex-start;
} }
...@@ -555,6 +211,27 @@ onMounted(async () => { ...@@ -555,6 +211,27 @@ onMounted(async () => {
box-shadow: none; box-shadow: none;
border: 0; border: 0;
width: 100%; width: 100%;
max-width: none;
}
// 轻量水印(P1):每页固定覆盖,避免截图外传
.print-watermark {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
transform: rotate(-28deg);
font-size: 24px;
font-weight: 700;
letter-spacing: 3px;
color: #000;
opacity: 0.07;
pointer-events: none;
z-index: 9999;
white-space: nowrap;
} }
} }
</style> </style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment