diff --git a/TUTORIAL.md b/TUTORIAL.md index b3b3798..f032e25 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -23,7 +23,7 @@ 1. 在"教师画像"表点顶栏「赛道匹配」;可只填某位教师,缺省匹配全部 2. 提交后稍等,切到"推荐报告"表查看每人的 Top 3 与可匹配赛道数 -3. 点顶栏「生成建议书」重建 HTML;左侧报告区打开 `report-编号` 查看 Top 3 卡片、分层清单与剔除原因 +3. 点顶栏「生成建议书」重建 HTML(在"推荐报告"表选中某一行则只重建该报告,未选中则重建最近 10 份);左侧报告区打开 `report-编号` 查看 Top 3 卡片、分层清单与剔除原因 ## 入口二:对话任务 @@ -67,3 +67,4 @@ - **所有赛道都是"不建议"**:研究标签与赛道需求标签重合度低;请教师补充/细化标签后重新匹配 - **看不到建议书**:先在"推荐报告"表点一次「生成建议书」,再到左侧 HTML 报告区打开 - **批量匹配没有结果**:确认 input 目录里的 JSON 是教师对象数组,且赛道库不为空 +- **批量匹配少了某位老师**:该教师画像不完整(研究标签与论文都没有)会被跳过并记录原因,补录标签后重新匹配即可 diff --git a/assets/actions.json b/assets/actions.json index 9b2642b..5465230 100644 --- a/assets/actions.json +++ b/assets/actions.json @@ -329,17 +329,31 @@ { "id": "render-html", "label": "生成建议书", - "description": "重建《申报策略建议书》HTML(缺省最近 10 份)。", + "description": "重建《申报策略建议书》HTML(未选择报告行时重建最近 10 份)。", "placements": ["toolbar", "skill-detail", "agent"], "executionProfile": "sync", "interaction": "direct", "bind": { - "tables": ["match_reports"] + "tables": ["match_reports"], + "inputMapping": { + "id": "$row.$pk" + } + }, + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "number", + "title": "报告编号", + "description": "重建该报告的建议书。未选择行时留空,重建最近 10 份。", + "readOnly": true + } + } }, "entrypoint": { "type": "cli", "command": "render-html", - "args": [] + "args": ["--report-id", "{{id}}"] }, "steps": [ { diff --git a/references/ACTIONS.md b/references/ACTIONS.md index 642230c..ebc4948 100644 --- a/references/ACTIONS.md +++ b/references/ACTIONS.md @@ -777,6 +777,12 @@ HINT: 完整 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 已占满右侧预览区,**技能页不应再人为收窄正文**(尤其数据管理窗口通常较窄)。 diff --git a/scripts/cli/app.py b/scripts/cli/app.py index 9fcbd31..a830162 100644 --- a/scripts/cli/app.py +++ b/scripts/cli/app.py @@ -109,8 +109,10 @@ def _handle_list_reports(args: argparse.Namespace) -> int: def _handle_render_html(args: argparse.Namespace) -> int: - report_id = getattr(args, "report_id", None) - return cmd_render_html(report_id=int(report_id) if report_id else None) + # 宿主行上下文可能给空串或未解析占位符(如 {{id}}):解析失败一律回退 + # 缺省行为(重建最近 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: @@ -195,7 +197,7 @@ def build_parser() -> ZhArgumentParser: sp.set_defaults(handler=lambda a: cmd_show_report(report_id=int(a.report_id))) 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 = sub.add_parser("stats", help="匹配统计") diff --git a/scripts/service/match_service.py b/scripts/service/match_service.py index 3809242..bde7b8b 100644 --- a/scripts/service/match_service.py +++ b/scripts/service/match_service.py @@ -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) -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 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) results = [] + skipped: list[dict[str, str]] = [] conn = get_conn() try: cur = conn.cursor() 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( """ INSERT INTO match_reports @@ -296,7 +318,7 @@ def run_match(teachers_raw: list[dict[str, Any]], tracks: list[dict[str, Any]], finally: 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] = {} for result in results: 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, "season": season, "count": len(results), + "skipped": skipped, "tier_counts": tier_counts, "brief_file": brief_file, "disclaimer": "推荐仅供参考,申报决策请结合个人规划与学院统筹。", "reports": results, } 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 diff --git a/scripts/service/report_service.py b/scripts/service/report_service.py index f3961c0..d5ce7c2 100644 --- a/scripts/service/report_service.py +++ b/scripts/service/report_service.py @@ -18,6 +18,14 @@ _TEMPLATE_REL = "assets/html/match-report.html" _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: 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: 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) - 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 @@ -116,8 +125,9 @@ def build_report_for_report_id(report_id: int) -> str: """按报告 id 从库中取数并重写 HTML(重建入口用)。""" from db.connection import get_conn - log = get_skill_logger() - log.info("report_render_start report_id=%s stage=render", report_id) + log = _log() + if log: + log.info("report_render_start report_id=%s stage=render", report_id) conn = get_conn() try: cur = conn.cursor() @@ -139,13 +149,20 @@ def build_report_for_report_id(report_id: int) -> str: result["top3"] = result.get("top") or [] html_text = build_match_html(result) 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 -def build_batch_brief(*, batch_no: str, results: list[dict[str, Any]], season: str) -> str: - """批量匹配汇总页:各教师 Top 1 一览。""" - log = get_skill_logger() +def build_batch_brief( + *, + batch_no: str, + results: list[dict[str, Any]], + season: str, + skipped: list[dict[str, Any]] | None = None, +) -> str: + """批量匹配汇总页:各教师 Top 1 一览;被跳过(画像待补录)教师单列。""" + log = _log() rows = [] for result in results: top3 = result.get("top3") or [] @@ -163,6 +180,13 @@ def build_batch_brief(*, batch_no: str, results: list[dict[str, Any]], season: s ) else: rows.append(f"{_esc(result.get('teacher_name'))}无可推荐赛道(画像待补全)") + for entry in skipped or []: + rows.append( + "" + f"{_esc(entry.get('teacher_name'))}" + f"画像待补录,本期未匹配({_esc(entry.get('reason'))})" + "" + ) if not rows: rows.append('本期无匹配结果。') @@ -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}, ) 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 diff --git a/scripts/service/task_service.py b/scripts/service/task_service.py index 9b9d44f..db0e66c 100644 --- a/scripts/service/task_service.py +++ b/scripts/service/task_service.py @@ -485,7 +485,9 @@ def cmd_match(*, teacher: str = "", year: Optional[int] = None) -> int: return 1 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["reports_brief"] = [ { @@ -499,9 +501,14 @@ def cmd_match(*, teacher: str = "", year: Optional[int] = None) -> int: for r in result["reports"] ] _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( status="success", - message=f"匹配完成 {result['count']} 名教师,建议书已生成", + message=message, skill=SKILL_SLUG, stage="match", batch_no=result["batch_no"], @@ -568,7 +575,7 @@ def cmd_match_batch_input() -> int: return 1 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): top = (report.get("top3") or [{}])[0] emit( @@ -577,16 +584,27 @@ def cmd_match_batch_input() -> int: skill=SKILL_SLUG, 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( { "ok": True, "batch_no": result["batch_no"], "count": result["count"], + "skipped": result.get("skipped") or [], "tier_counts": result["tier_counts"], "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 except JobStopped: raise diff --git a/tests/README.md b/tests/README.md index cd67d4a..f1f2204 100644 --- a/tests/README.md +++ b/tests/README.md @@ -103,6 +103,20 @@ python tests/run_tests.py test_cli_smoke - ``tests/.gitignore`` 忽略 ``__pycache__``、``.pytest_cache``、``*.pyc``、``desktop/artifacts/``、``integration/artifacts/``、``.env`` 等。 - 推荐运行前设置 ``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``) diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 34b29ac..42a06cb 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -12,8 +12,9 @@ | 文件 | 用途 | |------|------| -| `sample_request.json` | 通用请求体样例,供 ``tests/samples/test_golden_cases.py.sample`` 演示读取。 | -| `expected_response.json` | 与上配套的期望摘要字段(业务复制后按域替换)。 | +| `sample_request.json` | 教师画像金标准样例(脱敏,JSON 数组),可直接 `import-teachers --teachers-json` 消费;供 `tests/samples/test_golden_cases.py.sample` 演示读取。 | +| `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)的配置表达样例。 | --- diff --git a/tests/fixtures/expected_response.json b/tests/fixtures/expected_response.json index d031ce4..ee711a0 100644 --- a/tests/fixtures/expected_response.json +++ b/tests/fixtures/expected_response.json @@ -1,6 +1,22 @@ { - "success": true, - "data": { - "status": "ok" - } + "season": "2027", + "teacher_count": 2, + "track_count": 3, + "expectations": [ + { + "teacherName": "张教授", + "topTrack": "面上项目·光伏材料", + "tier": "强烈推荐", + "matchPct": 100, + "reasonsContains": ["命中需求标签", "优先资助方向命中"] + }, + { + "teacherName": "王讲师", + "topTrack": "青年项目·农村经济", + "tier": "可尝试", + "matchPct": 70, + "excludedContains": ["面上项目·光伏材料", "重点项目·量子计算"] + } + ], + "disclaimerContains": "仅供参考" } diff --git a/tests/fixtures/sample_request.json b/tests/fixtures/sample_request.json index 96ad266..f59db83 100644 --- a/tests/fixtures/sample_request.json +++ b/tests/fixtures/sample_request.json @@ -1,11 +1,22 @@ -{ - "request_id": "req_test_001", - "tenant_id": "tenant_demo", - "actor": "tester", - "parameters": { - "input_text": "demo input", - "options": { - "dry_run": true - } +[ + { + "teacherName": "张教授", + "department": "化材学院", + "title": "教授", + "researchTags": "钙钛矿,光伏,半导体,界面调控,稳定性", + "recentPapers": "钙钛矿电池界面钝化研究", + "projectHistory": "主持省自然科学基金", + "sciQ1Count": 3, + "corePaperCount": 8 + }, + { + "teacherName": "王讲师", + "department": "经管学院", + "title": "讲师", + "researchTags": "乡村振兴,农村经济", + "recentPapers": "县域乡村产业发展研究", + "projectHistory": "参与市社科基金", + "sciQ1Count": 0, + "corePaperCount": 1 } -} +] diff --git a/tests/fixtures/sample_tracks.json b/tests/fixtures/sample_tracks.json new file mode 100644 index 0000000..18e9ecb --- /dev/null +++ b/tests/fixtures/sample_tracks.json @@ -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年度申报指南(样例)" + } +] diff --git a/tests/samples/README.md b/tests/samples/README.md index 5880813..f4c3d32 100644 --- a/tests/samples/README.md +++ b/tests/samples/README.md @@ -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` diff --git a/tests/samples/test_golden_cases.py.sample b/tests/samples/test_golden_cases.py.sample index 6a35750..f40409e 100644 --- a/tests/samples/test_golden_cases.py.sample +++ b/tests/samples/test_golden_cases.py.sample @@ -26,11 +26,14 @@ class TestGoldenCasesSample(unittest.TestCase): req = json.load(f) with open(exp_path, encoding="utf-8") as f: exp = json.load(f) - self.assertEqual(req["request_id"], "req_test_001") - self.assertTrue(req["parameters"]["options"]["dry_run"]) - # 示意:out = run(req); 然后 assert out["success"] == exp["success"] 等关键字段 - self.assertTrue(exp["success"]) - self.assertEqual(exp["data"]["status"], "ok") + # 本技能:sample_request = 教师画像数组(import-teachers 可直接消费) + self.assertIsInstance(req, list) + self.assertEqual(req[0]["teacherName"], "张教授") + # 示意:run_match(req, tracks, exp["season"]) 后断言关键字段 + self.assertEqual(exp["season"], "2027") + top = exp["expectations"][0] + self.assertEqual(top["tier"], "强烈推荐") + self.assertEqual(top["matchPct"], 100) if __name__ == "__main__": diff --git a/tests/test_match_rules.py b/tests/test_match_rules.py index 1aff596..4daa124 100644 --- a/tests/test_match_rules.py +++ b/tests/test_match_rules.py @@ -167,5 +167,48 @@ class TestKernelAndPersistence(MatchRuleTestBase): 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__": unittest.main()