fix: 批量匹配跳过画像待补录教师 + render-html 按行重建 + 金标准 fixtures 业务化(P0+P1)

- run_match 新增 skip_incomplete:批量缺省跳过画像不完整教师并记录 skipped,指定单教师仍 PROFILE_INCOMPLETE;汇总页出"画像待补录"行
- render-html 补 --report-id 行上下文(inputMapping $row.$pk),CLI 容错解析回退最近 10 份
- report_service 日志改容错 _log(),修复未 setup 时单跑测试崩溃的顺序依赖
- fixtures 替换为教师画像/赛道/期望结论金标准样例,新增 sample_tracks.json
- 新增 4 个批量跳过用例(314 项,除 3 个已知 jsonschema 环境失败外全绿)
- tests/README 增补本机无共享 runtime 的测试方式;TUTORIAL/ACTIONS.md 同步表述
This commit is contained in:
chenfuqiang
2026-09-24 18:01:41 +08:00
parent 7c1f40b0e6
commit 4a8613b27e
15 changed files with 267 additions and 47 deletions

View File

@@ -23,7 +23,7 @@
1. 在"教师画像"表点顶栏「赛道匹配」;可只填某位教师,缺省匹配全部 1. 在"教师画像"表点顶栏「赛道匹配」;可只填某位教师,缺省匹配全部
2. 提交后稍等,切到"推荐报告"表查看每人的 Top 3 与可匹配赛道数 2. 提交后稍等,切到"推荐报告"表查看每人的 Top 3 与可匹配赛道数
3. 点顶栏「生成建议书」重建 HTML;左侧报告区打开 `report-编号` 查看 Top 3 卡片、分层清单与剔除原因 3. 点顶栏「生成建议书」重建 HTML(在"推荐报告"表选中某一行则只重建该报告,未选中则重建最近 10 份);左侧报告区打开 `report-编号` 查看 Top 3 卡片、分层清单与剔除原因
## 入口二:对话任务 ## 入口二:对话任务
@@ -67,3 +67,4 @@
- **所有赛道都是"不建议"**:研究标签与赛道需求标签重合度低;请教师补充/细化标签后重新匹配 - **所有赛道都是"不建议"**:研究标签与赛道需求标签重合度低;请教师补充/细化标签后重新匹配
- **看不到建议书**:先在"推荐报告"表点一次「生成建议书」,再到左侧 HTML 报告区打开 - **看不到建议书**:先在"推荐报告"表点一次「生成建议书」,再到左侧 HTML 报告区打开
- **批量匹配没有结果**:确认 input 目录里的 JSON 是教师对象数组,且赛道库不为空 - **批量匹配没有结果**:确认 input 目录里的 JSON 是教师对象数组,且赛道库不为空
- **批量匹配少了某位老师**:该教师画像不完整(研究标签与论文都没有)会被跳过并记录原因,补录标签后重新匹配即可

View File

@@ -329,17 +329,31 @@
{ {
"id": "render-html", "id": "render-html",
"label": "生成建议书", "label": "生成建议书",
"description": "重建《申报策略建议书》HTML(缺省最近 10 份)。", "description": "重建《申报策略建议书》HTML(未选择报告行时重建最近 10 份)。",
"placements": ["toolbar", "skill-detail", "agent"], "placements": ["toolbar", "skill-detail", "agent"],
"executionProfile": "sync", "executionProfile": "sync",
"interaction": "direct", "interaction": "direct",
"bind": { "bind": {
"tables": ["match_reports"] "tables": ["match_reports"],
"inputMapping": {
"id": "$row.$pk"
}
},
"inputSchema": {
"type": "object",
"properties": {
"id": {
"type": "number",
"title": "报告编号",
"description": "重建该报告的建议书。未选择行时留空,重建最近 10 份。",
"readOnly": true
}
}
}, },
"entrypoint": { "entrypoint": {
"type": "cli", "type": "cli",
"command": "render-html", "command": "render-html",
"args": [] "args": ["--report-id", "{{id}}"]
}, },
"steps": [ "steps": [
{ {

View File

@@ -777,6 +777,12 @@ HINT: <next step>
完整 JSON 形状见 [`../assets/examples/render-html.action.example.json`](../assets/examples/render-html.action.example.json)。最小页面壳见 [`../assets/html/example-report.html`](../assets/html/example-report.html)(已含数据管理 iframe 预览的布局基线;复制后可按需扩展 `{{BODY}}` 内结构)。 完整 JSON 形状见 [`../assets/examples/render-html.action.example.json`](../assets/examples/render-html.action.example.json)。最小页面壳见 [`../assets/html/example-report.html`](../assets/html/example-report.html)(已含数据管理 iframe 预览的布局基线;复制后可按需扩展 `{{BODY}}` 内结构)。
**本技能的行上下文写法**:`render-html` 的 `entrypoint.args` 为 `["--report-id", "{{id}}"]`,行 id 经 `bind.inputMapping: { "id": "$row.$pk" }` 注入、并在 `inputSchema.properties.id` 声明(模板参数必须声明,见上文规则)。行为约定:
- 数据管理在"推荐报告"表**选中某行**点「生成建议书」→ 只重建该报告(`report-{id}.html`)。
- 从 **HTML 右键重新生成 / skill-detail / 对话** 触发时**无行上下文**:宿主省略空的可选数字字段或占位未解析;CLI(`scripts/cli/app.py` `_handle_render_html`)对 `--report-id` 为 `""` / 非数字 / 缺值一律回退为"重建最近 10 份",不会报错。
- 因此 `--report-id` 在 argparse 上是可选值(`nargs="?"`),这是对宿主省略行为的容错,勿删。
### 宿主预览布局 ### 宿主预览布局
数据管理在 **iframe** 内预览 `html/*.html`;宿主 iframe 已占满右侧预览区,**技能页不应再人为收窄正文**(尤其数据管理窗口通常较窄)。 数据管理在 **iframe** 内预览 `html/*.html`;宿主 iframe 已占满右侧预览区,**技能页不应再人为收窄正文**(尤其数据管理窗口通常较窄)。

View File

@@ -109,8 +109,10 @@ def _handle_list_reports(args: argparse.Namespace) -> int:
def _handle_render_html(args: argparse.Namespace) -> int: def _handle_render_html(args: argparse.Namespace) -> int:
report_id = getattr(args, "report_id", None) # 宿主行上下文可能给空串或未解析占位符(如 {{id}}):解析失败一律回退
return cmd_render_html(report_id=int(report_id) if report_id else None) # 缺省行为(重建最近 10 份),不让 args 变体把渲染打崩。
report_id = _cli_int_or_none(getattr(args, "report_id", None))
return cmd_render_html(report_id=report_id)
def _print_full_usage() -> None: def _print_full_usage() -> None:
@@ -195,7 +197,7 @@ def build_parser() -> ZhArgumentParser:
sp.set_defaults(handler=lambda a: cmd_show_report(report_id=int(a.report_id))) sp.set_defaults(handler=lambda a: cmd_show_report(report_id=int(a.report_id)))
sp = sub.add_parser("render-html", help="重建《申报策略建议书》(缺省最近 10 份)") sp = sub.add_parser("render-html", help="重建《申报策略建议书》(缺省最近 10 份)")
sp.add_argument("--report-id", default=None, metavar="报告编号", dest="report_id") sp.add_argument("--report-id", default=None, nargs="?", metavar="报告编号", dest="report_id")
sp.set_defaults(handler=_handle_render_html) sp.set_defaults(handler=_handle_render_html)
sp = sub.add_parser("stats", help="匹配统计") sp = sub.add_parser("stats", help="匹配统计")

View File

@@ -247,8 +247,20 @@ def _write_report(result: dict[str, Any], batch_no: str) -> str:
return write_report_for_kernel(result=result, batch_no=batch_no) return write_report_for_kernel(result=result, batch_no=batch_no)
def run_match(teachers_raw: list[dict[str, Any]], tracks: list[dict[str, Any]], season: str, *, batch_no: str = "") -> dict[str, Any]: def run_match(
"""匹配内核:逐教师匹配 → 写库 → 生成建议书(多人时另出汇总页)。""" teachers_raw: list[dict[str, Any]],
tracks: list[dict[str, Any]],
season: str,
*,
batch_no: str = "",
skip_incomplete: bool = True,
) -> dict[str, Any]:
"""匹配内核:逐教师匹配 → 写库 → 生成建议书(多人时另出汇总页)。
skip_incomplete=True(批量缺省):画像不完整(E1)的教师被跳过并记入
``skipped``,不中断批次;skip_incomplete=False(指定单教师):直接抛
ValueError,由 CLI 映射 PROFILE_INCOMPLETE。
"""
from service.report_service import build_batch_brief from service.report_service import build_batch_brief
log = _log() log = _log()
@@ -263,11 +275,21 @@ def run_match(teachers_raw: list[dict[str, Any]], tracks: list[dict[str, Any]],
log.info("match_kernel_start batch_no=%s teachers=%s tracks=%s season=%s stage=kernel", batch_no, len(teachers_raw), len(tracks), season) log.info("match_kernel_start batch_no=%s teachers=%s tracks=%s season=%s stage=kernel", batch_no, len(teachers_raw), len(tracks), season)
results = [] results = []
skipped: list[dict[str, str]] = []
conn = get_conn() conn = get_conn()
try: try:
cur = conn.cursor() cur = conn.cursor()
for teacher_raw in teachers_raw: for teacher_raw in teachers_raw:
result = match_teacher(teacher_raw, tracks, season) try:
result = match_teacher(teacher_raw, tracks, season)
except ValueError as exc:
if not skip_incomplete:
raise
raw_name = str((teacher_raw or {}).get("teacherName") or (teacher_raw or {}).get("teacher_name") or "").strip() or "未命名教师"
skipped.append({"teacher_name": raw_name, "reason": str(exc)})
if log:
log.warning("match_teacher_skipped teacher=%s reason=%s stage=kernel", raw_name, exc)
continue
cur.execute( cur.execute(
""" """
INSERT INTO match_reports INSERT INTO match_reports
@@ -296,7 +318,7 @@ def run_match(teachers_raw: list[dict[str, Any]], tracks: list[dict[str, Any]],
finally: finally:
conn.close() conn.close()
brief_file = build_batch_brief(batch_no=batch_no, results=results, season=season) brief_file = build_batch_brief(batch_no=batch_no, results=results, season=season, skipped=skipped)
tier_counts: dict[str, int] = {} tier_counts: dict[str, int] = {}
for result in results: for result in results:
for tier, entries in result["tiers"].items(): for tier, entries in result["tiers"].items():
@@ -307,13 +329,19 @@ def run_match(teachers_raw: list[dict[str, Any]], tracks: list[dict[str, Any]],
"batch_no": batch_no, "batch_no": batch_no,
"season": season, "season": season,
"count": len(results), "count": len(results),
"skipped": skipped,
"tier_counts": tier_counts, "tier_counts": tier_counts,
"brief_file": brief_file, "brief_file": brief_file,
"disclaimer": "推荐仅供参考,申报决策请结合个人规划与学院统筹。", "disclaimer": "推荐仅供参考,申报决策请结合个人规划与学院统筹。",
"reports": results, "reports": results,
} }
if log: if log:
log.info("match_kernel_done batch_no=%s count=%s stage=kernel", batch_no, len(results)) log.info(
"match_kernel_done batch_no=%s count=%s skipped=%s stage=kernel",
batch_no,
len(results),
len(skipped),
)
return payload return payload

View File

@@ -18,6 +18,14 @@ _TEMPLATE_REL = "assets/html/match-report.html"
_TIER_BADGE = {"强烈推荐": "tier-strong", "可尝试": "tier-try", "不建议": "tier-not"} _TIER_BADGE = {"强烈推荐": "tier-strong", "可尝试": "tier-try", "不建议": "tier-not"}
def _log():
"""容错取 logger:未 setup_skill_logging(如单测直调 service)时返回 None。"""
try:
return get_skill_logger()
except RuntimeError:
return None
def _esc(value: Any) -> str: def _esc(value: Any) -> str:
return html_escape(str(value if value is not None else "—")) return html_escape(str(value if value is not None else "—"))
@@ -106,9 +114,10 @@ def build_match_html(result: dict[str, Any], batch_no: str = "") -> str:
def write_report_for_kernel(*, result: dict[str, Any], batch_no: str) -> str: def write_report_for_kernel(*, result: dict[str, Any], batch_no: str) -> str:
html_text = build_match_html(result, batch_no) html_text = build_match_html(result, batch_no)
log = get_skill_logger() log = _log()
path = write_html_page(f"report-{result['report_id']}.html", html_text) path = write_html_page(f"report-{result['report_id']}.html", html_text)
log.info("match_report_written report_id=%s path=%s stage=report", result["report_id"], path) if log:
log.info("match_report_written report_id=%s path=%s stage=report", result["report_id"], path)
return path return path
@@ -116,8 +125,9 @@ def build_report_for_report_id(report_id: int) -> str:
"""按报告 id 从库中取数并重写 HTML(重建入口用)。""" """按报告 id 从库中取数并重写 HTML(重建入口用)。"""
from db.connection import get_conn from db.connection import get_conn
log = get_skill_logger() log = _log()
log.info("report_render_start report_id=%s stage=render", report_id) if log:
log.info("report_render_start report_id=%s stage=render", report_id)
conn = get_conn() conn = get_conn()
try: try:
cur = conn.cursor() cur = conn.cursor()
@@ -139,13 +149,20 @@ def build_report_for_report_id(report_id: int) -> str:
result["top3"] = result.get("top") or [] result["top3"] = result.get("top") or []
html_text = build_match_html(result) html_text = build_match_html(result)
path = write_html_page(f"report-{report_id}.html", html_text) path = write_html_page(f"report-{report_id}.html", html_text)
log.info("report_render_done report_id=%s path=%s stage=render", report_id, path) if log:
log.info("report_render_done report_id=%s path=%s stage=render", report_id, path)
return path return path
def build_batch_brief(*, batch_no: str, results: list[dict[str, Any]], season: str) -> str: def build_batch_brief(
"""批量匹配汇总页:各教师 Top 1 一览。""" *,
log = get_skill_logger() batch_no: str,
results: list[dict[str, Any]],
season: str,
skipped: list[dict[str, Any]] | None = None,
) -> str:
"""批量匹配汇总页:各教师 Top 1 一览;被跳过(画像待补录)教师单列。"""
log = _log()
rows = [] rows = []
for result in results: for result in results:
top3 = result.get("top3") or [] top3 = result.get("top3") or []
@@ -163,6 +180,13 @@ def build_batch_brief(*, batch_no: str, results: list[dict[str, Any]], season: s
) )
else: else:
rows.append(f"<tr><td>{_esc(result.get('teacher_name'))}</td><td colspan='4' class='muted'>无可推荐赛道(画像待补全)</td></tr>") rows.append(f"<tr><td>{_esc(result.get('teacher_name'))}</td><td colspan='4' class='muted'>无可推荐赛道(画像待补全)</td></tr>")
for entry in skipped or []:
rows.append(
"<tr>"
f"<td>{_esc(entry.get('teacher_name'))}</td>"
f"<td colspan='4' class='muted'>画像待补录,本期未匹配({_esc(entry.get('reason'))})</td>"
"</tr>"
)
if not rows: if not rows:
rows.append('<tr><td colspan="5" class="muted">本期无匹配结果。</td></tr>') rows.append('<tr><td colspan="5" class="muted">本期无匹配结果。</td></tr>')
@@ -181,5 +205,6 @@ def build_batch_brief(*, batch_no: str, results: list[dict[str, Any]], season: s
{"TITLE": "申报赛道匹配汇总", "GENERATED_AT": str(season), "BODY": body}, {"TITLE": "申报赛道匹配汇总", "GENERATED_AT": str(season), "BODY": body},
) )
path = write_html_page(f"brief-{batch_no}.html", page) path = write_html_page(f"brief-{batch_no}.html", page)
log.info("match_brief_written batch_no=%s path=%s stage=report", batch_no, path) if log:
log.info("match_brief_written batch_no=%s path=%s stage=report", batch_no, path)
return path return path

View File

@@ -485,7 +485,9 @@ def cmd_match(*, teacher: str = "", year: Optional[int] = None) -> int:
return 1 return 1
season = str(year or tracks[0].get("year") or "") season = str(year or tracks[0].get("year") or "")
result = run_match(teachers, tracks, season) # 指定教师 → 严格模式,画像不完整直接 PROFILE_INCOMPLETE;
# 缺省全量 → 跳过画像待补录教师并记录,不中断批次(E1)。
result = run_match(teachers, tracks, season, skip_incomplete=not teacher)
payload = {k: v for k, v in result.items() if k != "reports"} payload = {k: v for k, v in result.items() if k != "reports"}
payload["reports_brief"] = [ payload["reports_brief"] = [
{ {
@@ -499,9 +501,14 @@ def cmd_match(*, teacher: str = "", year: Optional[int] = None) -> int:
for r in result["reports"] for r in result["reports"]
] ]
_print_json(payload) _print_json(payload)
skipped_count = len(result.get("skipped") or [])
message = f"匹配完成 {result['count']} 名教师,建议书已生成"
if skipped_count:
message += f";{skipped_count} 名教师画像待补录已跳过"
log.info("match_skipped count=%s stage=match", skipped_count)
finish( finish(
status="success", status="success",
message=f"匹配完成 {result['count']} 名教师,建议书已生成", message=message,
skill=SKILL_SLUG, skill=SKILL_SLUG,
stage="match", stage="match",
batch_no=result["batch_no"], batch_no=result["batch_no"],
@@ -568,7 +575,7 @@ def cmd_match_batch_input() -> int:
return 1 return 1
season = str(tracks[0].get("year") or "") season = str(tracks[0].get("year") or "")
result = run_match(teachers, tracks, season) result = run_match(teachers, tracks, season, skip_incomplete=True)
for index, report in enumerate(result["reports"], start=1): for index, report in enumerate(result["reports"], start=1):
top = (report.get("top3") or [{}])[0] top = (report.get("top3") or [{}])[0]
emit( emit(
@@ -577,16 +584,27 @@ def cmd_match_batch_input() -> int:
skill=SKILL_SLUG, skill=SKILL_SLUG,
stage="batch", stage="batch",
) )
for entry in result.get("skipped") or []:
emit(
f"{entry.get('teacher_name')}:画像待补录,本期已跳过({entry.get('reason')})",
type="warn",
skill=SKILL_SLUG,
stage="batch",
)
_print_json( _print_json(
{ {
"ok": True, "ok": True,
"batch_no": result["batch_no"], "batch_no": result["batch_no"],
"count": result["count"], "count": result["count"],
"skipped": result.get("skipped") or [],
"tier_counts": result["tier_counts"], "tier_counts": result["tier_counts"],
"brief_file": result["brief_file"], "brief_file": result["brief_file"],
} }
) )
finish(status="success", message=f"批量匹配完成 {result['count']} 名教师", skill=SKILL_SLUG, stage="batch", batch_no=result["batch_no"]) message = f"批量匹配完成 {result['count']} 名教师"
if result.get("skipped"):
message += f",{len(result['skipped'])} 名画像待补录已跳过"
finish(status="success", message=message, skill=SKILL_SLUG, stage="batch", batch_no=result["batch_no"])
return 0 return 0
except JobStopped: except JobStopped:
raise raise

View File

@@ -103,6 +103,20 @@ python tests/run_tests.py test_cli_smoke
- ``tests/.gitignore`` 忽略 ``__pycache__``、``.pytest_cache``、``*.pyc``、``desktop/artifacts/``、``integration/artifacts/``、``.env`` 等。 - ``tests/.gitignore`` 忽略 ``__pycache__``、``.pytest_cache``、``*.pyc``、``desktop/artifacts/``、``integration/artifacts/``、``.env`` 等。
- 推荐运行前设置 ``PYTHONDONTWRITEBYTECODE=1`` 减少 ``.pyc``。 - 推荐运行前设置 ``PYTHONDONTWRITEBYTECODE=1`` 减少 ``.pyc``。
### 1.5 本机无共享 runtime 时怎么跑(本技能开发机约定)
宿主共享 runtime(``{JIANGCHANG_DATA_ROOT}/python-runtime/.venv``)只在宿主环境存在;纯开发机上 ``import jiangchang_skill_core`` 会直接 ``ModuleNotFoundError``(整套测试 60+ errors)。**不安装任何包**的跑法:
```powershell
$env:PYTHONPATH = "D:\jiangchang\huashang\jiangchang-platform-kit\jiangchang-platform-kit\src"
python tests\run_tests.py -v
```
已知环境性失败(不修、不装包):``test_actions_manifest.py`` 中 3 项依赖 ``jsonschema`` 的 schema 校验
(``test_schema_allows_all_placement_execution_profile_combinations`` / ``test_schema_allows_bind_label_column`` /
``test_schema_rejects_toolbar_without_bind``)会以 ``jsonschema is required`` 失败。**除这 3 项外须全部通过**;
接入宿主共享 runtime 后自动恢复。``requirements.txt`` 保持为空——平台库由宿主 runtime 提供,技能不声明平台依赖。
--- ---
## 2. 可选:Service / Golden(``tests/samples/*.sample``) ## 2. 可选:Service / Golden(``tests/samples/*.sample``)

View File

@@ -12,8 +12,9 @@
| 文件 | 用途 | | 文件 | 用途 |
|------|------| |------|------|
| `sample_request.json` | 通用请求体样例,供 ``tests/samples/test_golden_cases.py.sample`` 演示读取。 | | `sample_request.json` | 教师画像金标准样例(脱敏,JSON 数组),可直接 `import-teachers --teachers-json` 消费;供 `tests/samples/test_golden_cases.py.sample` 演示读取。 |
| `expected_response.json` | 与上配套的期望摘要字段(业务复制后按域替换)。 | | `sample_tracks.json` | 指南赛道金标准样例(脱敏,JSON 数组),可直接 `import-tracks --tracks-json` 消费;与上配套覆盖 H1/H2/S1~S3/T1 场景。 |
| `expected_response.json` | 与上两份配套的期望匹配结论(Top 赛道/分层/得分/剔除项,业务复制后按域替换)。 |
| `adapter_profiles.sample.yaml` | 五类运行方式(mock / simulator_api / simulator_rpa / real_api / real_rpa)的配置表达样例。 | | `adapter_profiles.sample.yaml` | 五类运行方式(mock / simulator_api / simulator_rpa / real_api / real_rpa)的配置表达样例。 |
--- ---

View File

@@ -1,6 +1,22 @@
{ {
"success": true, "season": "2027",
"data": { "teacher_count": 2,
"status": "ok" "track_count": 3,
} "expectations": [
{
"teacherName": "张教授",
"topTrack": "面上项目·光伏材料",
"tier": "强烈推荐",
"matchPct": 100,
"reasonsContains": ["命中需求标签", "优先资助方向命中"]
},
{
"teacherName": "王讲师",
"topTrack": "青年项目·农村经济",
"tier": "可尝试",
"matchPct": 70,
"excludedContains": ["面上项目·光伏材料", "重点项目·量子计算"]
}
],
"disclaimerContains": "仅供参考"
} }

View File

@@ -1,11 +1,22 @@
{ [
"request_id": "req_test_001", {
"tenant_id": "tenant_demo", "teacherName": "张教授",
"actor": "tester", "department": "化材学院",
"parameters": { "title": "教授",
"input_text": "demo input", "researchTags": "钙钛矿,光伏,半导体,界面调控,稳定性",
"options": { "recentPapers": "钙钛矿电池界面钝化研究",
"dry_run": true "projectHistory": "主持省自然科学基金",
} "sciQ1Count": 3,
"corePaperCount": 8
},
{
"teacherName": "王讲师",
"department": "经管学院",
"title": "讲师",
"researchTags": "乡村振兴,农村经济",
"recentPapers": "县域乡村产业发展研究",
"projectHistory": "参与市社科基金",
"sciQ1Count": 0,
"corePaperCount": 1
} }
} ]

38
tests/fixtures/sample_tracks.json vendored Normal file
View File

@@ -0,0 +1,38 @@
[
{
"year": 2027,
"agency": "数理学部",
"programName": "面上项目",
"trackName": "光伏材料",
"trackTags": "钙钛矿,光伏,半导体",
"priorityKeywords": "界面调控,稳定性",
"minTitle": "副高及以上",
"minSciQ1": 0,
"quota": 10,
"sourceDoc": "2027年度申报指南(样例)"
},
{
"year": 2027,
"agency": "管理学部",
"programName": "青年项目",
"trackName": "农村经济",
"trackTags": "乡村振兴,农村经济",
"priorityKeywords": "数字经济",
"minTitle": "",
"minSciQ1": 0,
"quota": 8,
"sourceDoc": "2027年度申报指南(样例)"
},
{
"year": 2027,
"agency": "信息学部",
"programName": "重点项目",
"trackName": "量子计算",
"trackTags": "量子计算,量子信息",
"priorityKeywords": "",
"minTitle": "正高及以上",
"minSciQ1": 5,
"quota": 3,
"sourceDoc": "2027年度申报指南(样例)"
}
]

View File

@@ -40,7 +40,7 @@
- **计算类**:费用、税费、分摊、账单字段。 - **计算类**:费用、税费、分摊、账单字段。
- **校验类**:合规、字段一致性、状态机规则。 - **校验类**:合规、字段一致性、状态机规则。
推荐配合根目录下的 **`tests/fixtures/sample_request.json`** 与 **`tests/fixtures/expected_response.json`**(复制技能后按域替换字段,保持脱敏)。 推荐配合根目录下的 **`tests/fixtures/sample_request.json`**、**`tests/fixtures/sample_tracks.json`** 与 **`tests/fixtures/expected_response.json`**(复制技能后按域替换字段,保持脱敏)。
### 5. `test_logging_contract.py.sample` ### 5. `test_logging_contract.py.sample`

View File

@@ -26,11 +26,14 @@ class TestGoldenCasesSample(unittest.TestCase):
req = json.load(f) req = json.load(f)
with open(exp_path, encoding="utf-8") as f: with open(exp_path, encoding="utf-8") as f:
exp = json.load(f) exp = json.load(f)
self.assertEqual(req["request_id"], "req_test_001") # 本技能:sample_request = 教师画像数组(import-teachers 可直接消费)
self.assertTrue(req["parameters"]["options"]["dry_run"]) self.assertIsInstance(req, list)
# 示意:out = run(req); 然后 assert out["success"] == exp["success"] 等关键字段 self.assertEqual(req[0]["teacherName"], "张教授")
self.assertTrue(exp["success"]) # 示意:run_match(req, tracks, exp["season"]) 后断言关键字段
self.assertEqual(exp["data"]["status"], "ok") self.assertEqual(exp["season"], "2027")
top = exp["expectations"][0]
self.assertEqual(top["tier"], "强烈推荐")
self.assertEqual(top["matchPct"], 100)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -167,5 +167,48 @@ class TestKernelAndPersistence(MatchRuleTestBase):
self.assertEqual(payload["tracks_by_year"].get("2027"), 4) self.assertEqual(payload["tracks_by_year"].get("2027"), 4)
class TestBatchSkipIncomplete(MatchRuleTestBase):
"""批量缺省跳过画像不完整教师(E1),指定单教师保持严格失败。"""
def test_batch_skips_incomplete_teacher(self) -> None:
from service.match_service import run_match
incomplete = _teacher(teacherName="王讲师", researchTags="", recentPapers="")
payload = run_match([_teacher(), incomplete], _tracks(), "2027")
self.assertEqual(payload["ok"], True)
self.assertEqual(payload["count"], 1)
self.assertEqual(len(payload["skipped"]), 1)
skipped = payload["skipped"][0]
self.assertEqual(skipped["teacher_name"], "王讲师")
self.assertIn("E1", skipped["reason"])
# 正常教师仍有报告
self.assertEqual(len(payload["reports"]), 1)
self.assertEqual(payload["reports"][0]["teacher_name"], "张教授")
def test_batch_brief_marks_skipped_teacher(self) -> None:
from service.match_service import run_match
incomplete = _teacher(teacherName="王讲师", researchTags="", recentPapers="")
payload = run_match([_teacher(), incomplete], _tracks(), "2027")
with open(payload["brief_file"], encoding="utf-8") as f:
html_text = f.read()
self.assertIn("画像待补录", html_text)
self.assertIn("王讲师", html_text)
def test_all_skipped_still_returns_success_payload(self) -> None:
from service.match_service import run_match
payload = run_match([_teacher(researchTags="", recentPapers="")], _tracks(), "2027")
self.assertEqual(payload["ok"], True)
self.assertEqual(payload["count"], 0)
self.assertEqual(len(payload["skipped"]), 1)
def test_strict_mode_raises_for_incomplete(self) -> None:
from service.match_service import run_match
with self.assertRaises(ValueError):
run_match([_teacher(researchTags="", recentPapers="")], _tracks(), "2027", skip_incomplete=False)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()