diff --git a/.agents/skills/offeru/SKILL.md b/.agents/skills/offeru/SKILL.md index 3d847c5d..0b7912b8 100644 --- a/.agents/skills/offeru/SKILL.md +++ b/.agents/skills/offeru/SKILL.md @@ -5,7 +5,7 @@ user-invocable: true argument-hint: "[skill-id | goal | JD/URL]" --- - + # OfferU External-Agent Router diff --git a/.claude/agents/offeru-operator.md b/.claude/agents/offeru-operator.md index 4a19e709..ee780edc 100644 --- a/.claude/agents/offeru-operator.md +++ b/.claude/agents/offeru-operator.md @@ -7,7 +7,7 @@ skills: - offeru --- - + You are the OfferU operator subagent. Work from `backend/` and treat the live CLI manifest as the only capability source. diff --git a/.claude/skills/offeru/SKILL.md b/.claude/skills/offeru/SKILL.md index 3d847c5d..0b7912b8 100644 --- a/.claude/skills/offeru/SKILL.md +++ b/.claude/skills/offeru/SKILL.md @@ -5,7 +5,7 @@ user-invocable: true argument-hint: "[skill-id | goal | JD/URL]" --- - + # OfferU External-Agent Router diff --git a/.codex/agents/offeru-operator.toml b/.codex/agents/offeru-operator.toml index 65bca689..b4b5f094 100644 --- a/.codex/agents/offeru-operator.toml +++ b/.codex/agents/offeru-operator.toml @@ -1,4 +1,4 @@ -# generated: offeru-skill-registry@2026-07-30.2 sha256=bc92f50701240cdc9c8f42c63a8dd440cccce7aa8af5c7b543f8045dc11debaf +# generated: offeru-skill-registry@2026-07-30.2 sha256=aaed3fc9f2d46ef3564d49f1a20fdd408c03779eea2554b710b7274118406f23 name = "offeru-operator" description = "Operate OfferU through its live Skill Registry and atomic CLI control contract." developer_instructions = """ diff --git a/.copilot/SKILL.md b/.copilot/SKILL.md index 3d847c5d..0b7912b8 100644 --- a/.copilot/SKILL.md +++ b/.copilot/SKILL.md @@ -5,7 +5,7 @@ user-invocable: true argument-hint: "[skill-id | goal | JD/URL]" --- - + # OfferU External-Agent Router diff --git a/AGENTS.md b/AGENTS.md index a5b133a6..b9c7b697 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,8 @@ Playwright MCP 或Browser 来访问/截图/识别/探索网站的视觉和代码 - **扩展验收同样不得选系统浏览器**:`extension/scripts/` 下的 fixture、smoke 和 E2E 脚本必须使用 Playwright 自带的 managed Chromium、临时隔离 profile 与 `headless: true`;不得扫描或传入 Chrome/Edge 可执行文件路径。 - **仓库内所有自动浏览器脚本都遵守同一边界**:包括根目录临时/历史脚本;统一使用 Playwright managed Chromium 与 `headless: true`,不得保留系统 Chrome/Edge 的 `executablePath`、channel 或可见窗口入口。用户主动触发的授权登录窗口是唯一例外,且不属于自动验收。 - **测试临时空间固定使用 H 盘**:Agent、浏览器、后端、前端和扩展测试产生的工作目录、缓存、隔离 profile、日志与截图统一放到 `H:\tmp\offeru` 或其子目录;不得把测试临时目录写入 C 盘。已安装的 Agent 可执行文件及其原生认证目录只读使用,不搬迁、不清理用户凭据。 -- **旧二进制不得作为入口**:仓库根目录若存在版本低于当前 Release 的 `OfferU.exe`(当前发现为历史 `0.1.0`),不得自动启动、覆盖或作为验收依据;当前源码开发只使用 `DEVELOPMENT.md` 的 `7410/8766` 进程,正式使用只接受经过 Release Gate 的安装包。 +- **Agent-native 产品验收不得用 Playwright 代替 Agent 操作**:当验收目标是“本地 Coding Agent 使用 OfferU”时,Coding Agent 必须通过 OfferU Skill → CLI/Bridge → Operation Registry 操作业务能力;Playwright 只能作为独立的前端回归测试,不得充当 Agent。用户可以自行打开可见 OfferU 前端作为观察、编辑和 HITL 确认界面,这不属于自动浏览器验收,也不受 `headless=true` 限制。 +- **禁止 scripted executor 冒充 Agent**:预先根据 prompt 关键字写死 Operation 序列的 deterministic executor 只能标为 workflow/smoke,不得标记为 OMP/SWE-2 Agent E2E;Agent E2E 必须保留可验证的模型身份和 model-issued tool calls。 ## Agent skills diff --git a/backend/app/cli.py b/backend/app/cli.py index 631ac5f1..9f710b9d 100644 --- a/backend/app/cli.py +++ b/backend/app/cli.py @@ -16,7 +16,12 @@ from app.ops import get_operation_schema, list_operations from app.bridge_cli import main as bridge_main from app.runtime_paths import runtime_data_dir, runtime_uploads_dir -from app.services.agent_skill_registry import catalog, registry_snapshot, resolve_skill +from app.services.agent_skill_registry import ( + agent_operation_names, + catalog, + registry_snapshot, + resolve_skill, +) from app.services.agent_host_registry import host_capability_matrix from app.services.operation_projection import ( confirm_operation_proposal, @@ -107,6 +112,12 @@ def main(argv: Optional[list[str]] = None) -> int: { "ok": True, "operation_count": len(list_operations()), + "operation_registry_count": len(list_operations()), + "agent_tool_count": len(agent_operation_names()), + "featured_tool_count": len(agent_operation_names(featured_only=True)), + "internal_operation_count": ( + len(list_operations()) - len(agent_operation_names()) + ), "returned_count": len(operations), "selector": selector, "operations": operations, @@ -154,6 +165,9 @@ def main(argv: Optional[list[str]] = None) -> int: args.pretty, exit_code=0 if result.get("ok") else 1, ) + if args.command == "ui": + from app.ui_cli import main as ui_main + return ui_main(["ui"] + args.ui_args) return _print({"ok": False, "errors": ["缺少命令"], "commands": _commands()}, exit_code=2) except KeyboardInterrupt: return _print({"ok": False, "errors": ["interrupted"]}, getattr(args, "pretty", False), exit_code=130) @@ -189,11 +203,19 @@ def _build_parser() -> JsonArgumentParser: manifest_selector.add_argument("--group", default="", help="Show compact Operation summaries for one group.") manifest_selector.add_argument("--all", dest="all_operations", action="store_true", help="Show the full developer audit manifest.") - ops = sub.add_parser("ops", help="Discover atomic internal operations.", add_help=False) + ops = sub.add_parser( + "ops", + help="Discover Agent-facing OfferU tools; --all is the raw Operation Registry audit.", + add_help=False, + ) ops.add_argument("--pretty", action="store_true", help="Pretty-print JSON.") ops_selector = ops.add_mutually_exclusive_group() ops_selector.add_argument("--skill", default="", help="List compact Operations for one Skill.") - ops_selector.add_argument("--group", default="", help="List compact Operations for one group.") + ops_selector.add_argument( + "--group", + default="", + help="List Agent-exposed Operations for one group; internal/legacy entries stay hidden.", + ) ops_selector.add_argument("--all", dest="all_operations", action="store_true", help="List every full Operation schema for auditing.") schema = sub.add_parser("schema", help="Show one operation schema.", add_help=False) @@ -246,11 +268,15 @@ def _build_parser() -> JsonArgumentParser: ) conformance.add_argument("--refresh", action="store_true", help="Bypass local version probe cache.") conformance.add_argument("--pretty", action="store_true", help="Pretty-print JSON.") + + ui = sub.add_parser("ui", help="UI automation commands (open/click/fill/eval/screenshot/content/wait).", add_help=False) + ui.add_argument("ui_args", nargs=argparse.REMAINDER, help="Arguments passed to ui command.") + return parser def _commands() -> list[str]: - return ["doctor", "manifest", "ops", "schema", "run", "confirm", "conformance", "bridge"] + return ["doctor", "manifest", "ops", "schema", "run", "confirm", "conformance", "bridge", "ui"] def _doctor() -> dict[str, Any]: @@ -716,14 +742,13 @@ def _select_operations( known_groups = {str(operation.get("group") or "ungrouped") for operation in operations} if clean_group not in known_groups: raise ValueError(f"未知能力组: {clean_group}") + # Group discovery is an Agent surface, not a raw Registry dump. UI-only, + # migration, diagnostic and compatibility Operations stay reachable by + # exact schema/run name and by the explicit --all developer audit path. + selected_names = agent_operation_names() selector = f"group:{clean_group}" elif default_featured and not all_operations: - selected_names = { - str(operation_name) - for skill_item in catalog() - if skill_item.get("featured") - for operation_name in skill_item.get("allowed_tools", []) - } + selected_names = agent_operation_names(featured_only=True) selector = "featured_skills" if selected_names is not None: operations = [operation for operation in operations if operation.get("name") in selected_names] @@ -776,6 +801,13 @@ def _manifest(*, skill: str = "", group: str = "", all_operations: bool = False) "sha256": full_registry["sha256"], "skills": [_summarize_skill(item) for item in full_registry["skills"]], } + agent_names = agent_operation_names() + featured_names = agent_operation_names(featured_only=True) + agent_schemas = [ + operation + for operation in operation_schemas + if str(operation.get("name") or "") in agent_names + ] return { "ok": True, "service": "OfferU CLI", @@ -809,10 +841,18 @@ def _manifest(*, skill: str = "", group: str = "", all_operations: bool = False) "raw_api_capability": False, "side_effect_labels": sorted({effect for op in operation_schemas for effect in op.get("side_effects", [])}), }, + # operation_count is retained for backwards compatibility. New callers + # should distinguish the governed Registry from the model-facing Tool + # surface instead of treating every Operation as an Agent tool. "operation_count": len(operation_schemas), + "operation_registry_count": len(operation_schemas), + "agent_tool_count": len(agent_names), + "featured_tool_count": len(featured_names), + "internal_operation_count": len(operation_schemas) - len(agent_names), "returned_count": len(operations), "selector": selector, "groups": _groups(operation_schemas), + "agent_groups": _groups(agent_schemas), "operations": operations, "skill_registry": skills, # Host × Skill support matrix (full/limited/unsupported) so an external diff --git a/backend/app/ops.py b/backend/app/ops.py index 80ccd651..4c0965b9 100644 --- a/backend/app/ops.py +++ b/backend/app/ops.py @@ -30,14 +30,13 @@ ) from app.services.privacy_hygiene import ( get_privacy_hygiene_status, - get_synthetic_email_test_data_status, purge_synthetic_email_test_data, scrub_legacy_email_notification_bodies, ) from app.services.job_ingest import JobIngestItem, import_job_batch from app.services.job_sources.protocol import JobSearchQuery from app.services.job_sources.router import job_source_router -from app.services.application_actions import preview_application_action +from app.services.application_actions import list_application_action_connectors, preview_application_action from app.services.scraper_operations import finalize_scraper_batch, start_scraper_batch from app.services.harness_operations import ( delete_harness_conversation, @@ -92,7 +91,6 @@ get_hosted_executor_session, get_career_task, get_career_task_result, - get_agent_provider_health, get_agent_connections, get_local_agent_capability_matrix, get_local_agent_capability_report, @@ -217,7 +215,6 @@ duplicate_resume_template, extract_interview_questions, generate_html_resume, - generate_legacy_cover_letter, generate_legacy_interview_answer, import_jobs_to_application_table, import_latest_extension_batch_to_application_table, @@ -889,11 +886,6 @@ class ResumeTemplateDuplicateInput(_StrictOperationInput): new_name: str = Field(min_length=1, max_length=300) -class LegacyCoverLetterInput(_StrictOperationInput): - job_id: int = Field(gt=0) - resume_id: int = Field(gt=0) - - class GenerateHtmlResumeInput(_StrictOperationInput): profile_id: int = Field(gt=0) template_id: int = Field(gt=0) @@ -2272,6 +2264,16 @@ async def _search_jobs_via_sources( input_model=PreviewApplicationActionInput, version="2026-09-21", ), + "list_application_action_connectors": Operation( + name="list_application_action_connectors", + fn=list_application_action_connectors, + description="列出已注册站外投递动作 Connector 的能力矩阵(greet/send_message/send_resume/exchange_contact)与可用性;第一阶段无写执行器,仅声明边界,任何真实写都须走受保护 Operation + Proposal/HITL。", + parameters={}, + group="applications", + side_effects=("read",), + input_model=_StrictOperationInput, + version="2026-09-21", + ), "create_application_attempt": Operation( name="create_application_attempt", fn=create_application_attempt, @@ -2565,14 +2567,6 @@ async def _search_jobs_via_sources( input_model=DelegateCareerTaskInput, version="2026-08-27", ), - "get_agent_provider_health": Operation( - name="get_agent_provider_health", - fn=get_agent_provider_health, - description="读取一个 Agent Runtime provider 的脱敏可用性、认证与阻塞状态。", - group="agent_runtime", - input_model=ProviderHealthInput, - version="2026-08-27", - ), "list_agent_provider_health": Operation( name="list_agent_provider_health", fn=list_agent_provider_health, @@ -3114,13 +3108,6 @@ async def _search_jobs_via_sources( group="governance", version="2026-08-31", ), - "get_synthetic_email_test_data_status": Operation( - name="get_synthetic_email_test_data_status", - fn=get_synthetic_email_test_data_status, - description="读取严格限定的合成邮箱测试数据计数,不返回账号地址或邮件内容。", - group="governance", - version="2026-08-31", - ), "scrub_legacy_email_notification_bodies": Operation( name="scrub_legacy_email_notification_bodies", fn=scrub_legacy_email_notification_bodies, @@ -3510,14 +3497,6 @@ async def _search_jobs_via_sources( side_effects=("llm", "write"), input_model=GenerateLegacyInterviewAnswerInput, ), - "generate_legacy_cover_letter": Operation( - name="generate_legacy_cover_letter", - fn=generate_legacy_cover_letter, - description="兼容旧投递接口生成求职信草稿。", - group="applications", - side_effects=("llm",), - input_model=LegacyCoverLetterInput, - ), "create_resume_template": Operation( name="create_resume_template", fn=create_resume_template, diff --git a/backend/app/routes/applications.py b/backend/app/routes/applications.py index b01e8fef..afcf905e 100644 --- a/backend/app/routes/applications.py +++ b/backend/app/routes/applications.py @@ -376,7 +376,7 @@ async def create_application(data: ApplicationCreate): @router.post("/generate") async def generate(data: GenerateRequest): - return await _execute_operation("generate_legacy_cover_letter", data.model_dump()) + return await _execute_operation("generate_cover_letter", data.model_dump()) @router.get("/stats") diff --git a/backend/app/services/agent_operations.py b/backend/app/services/agent_operations.py index 42c7fb87..59f46f7a 100644 --- a/backend/app/services/agent_operations.py +++ b/backend/app/services/agent_operations.py @@ -1097,12 +1097,6 @@ async def delegate_career_task( ) -async def get_agent_provider_health(provider_id: str) -> dict: - from app.services.agent_provider_health import get_provider_health - - return await get_provider_health(provider_id) - - async def list_agent_provider_health() -> dict: from app.services.agent_provider_health import list_provider_health diff --git a/backend/app/services/agent_skill_registry.py b/backend/app/services/agent_skill_registry.py index 4066791b..a068ede9 100644 --- a/backend/app/services/agent_skill_registry.py +++ b/backend/app/services/agent_skill_registry.py @@ -84,7 +84,7 @@ def _skill( _skill("resume_export", "简历导出", "documents", "native", "检查当前简历并原子导出 ATS 友好的 PDF。", "skill_assistant", ("list_resumes", "get_resume", "export_resume_pdf"), featured=False, order=80, aliases=("pdf", "export")), _skill("cover_letter", "求职信", "documents", "native", "基于真实岗位和简历生成并持久化可审阅求职信。", "skill_assistant", ("get_profile", "list_jobs", "get_job", "list_resumes", "get_resume", "list_career_artifacts", "get_career_artifact", "generate_cover_letter", "save_career_artifact"), featured=False, order=90, aliases=("cover", "求职信")), _skill("application_email", "申请邮件", "documents", "native", "生成并持久化正式申请邮件草稿,永不发送。", "skill_assistant", ("get_profile", "list_jobs", "get_job", "list_resumes", "get_resume", "get_application_workspace", "list_career_artifacts", "get_career_artifact", "save_career_artifact"), featured=False, order=100, aliases=("email", "申请邮件")), - _skill("application_assistant", "投递助手", "applications", "partial", "起草投递材料、预演站外动作并登记待办;外部写入永远停在 OfferU Proposal/HITL。", "skill_assistant", ("get_profile", "list_jobs", "get_job", "get_pre_application_state", "list_resumes", "get_resume", "list_applications", "get_application_workspace", "preview_application_action", "list_career_artifacts", "get_career_artifact", "generate_cover_letter", "save_career_artifact", "create_application", "update_application_record"), featured=True, order=110, missing=("ApplicationActionConnector 外部执行器",), aliases=("apply", "投递")), + _skill("application_assistant", "投递助手", "applications", "partial", "起草投递材料、预演站外动作并登记待办;外部写入永远停在 OfferU Proposal/HITL。", "skill_assistant", ("get_profile", "list_jobs", "get_job", "get_pre_application_state", "list_resumes", "get_resume", "list_applications", "get_application_workspace", "preview_application_action", "list_application_action_connectors", "list_career_artifacts", "get_career_artifact", "generate_cover_letter", "save_career_artifact", "create_application", "update_application_record"), featured=True, order=110, missing=("ApplicationActionConnector 外部执行器",), aliases=("apply", "投递")), _skill("tracker", "投递追踪", "applications", "native", "以当前投递工作区和追加式事件时间线为事实源,汇总状态、遗漏与下一步。", "skill_assistant", ("list_applications", "get_application_workspace", "list_application_records", "list_application_events", "analyze_application_patterns", "list_follow_up_cadence", "update_application_status", "update_application_record"), featured=True, order=120, aliases=("tracker", "投递管理")), _skill("follow_up", "跟进节奏", "applications", "native", "按确定性节奏计算到期跟进,生成草稿;只有确认已发送后才记账。", "skill_assistant", ("get_profile", "list_applications", "get_application_workspace", "list_application_events", "list_application_progress_candidates", "get_application_progress_candidate", "get_application_progress_overview", "list_follow_up_cadence", "list_calendar_events", "email_connection_status", "list_email_accounts", "list_email_sync_runs", "get_email_sync_run", "list_career_artifacts", "get_career_artifact", "save_career_artifact", "record_follow_up", "review_application_progress", "update_application_status", "update_application_record", "sync_email_notifications", "revoke_email_account"), featured=True, order=130, aliases=("followup", "follow_up", "跟进")), _skill("reply_watch", "回复识别", "applications", "native", "用 Gmail historyId 或 IMAP UID 增量同步邮箱,把消息保存为候选进展;只有使用者确认后才追加投递阶段事件。", "skill_assistant", ("list_applications", "get_application_workspace", "list_application_events", "list_application_progress_candidates", "get_application_progress_candidate", "get_application_progress_overview", "email_connection_status", "list_email_accounts", "list_email_sync_runs", "get_email_sync_run", "list_career_artifacts", "get_career_artifact", "save_career_artifact", "review_application_progress", "sync_email_notifications", "revoke_email_account"), featured=False, order=140, aliases=("reply", "回复识别")), @@ -134,6 +134,26 @@ def catalog() -> list[dict[str, Any]]: return [skill.summary() for skill in sorted(skills, key=lambda item: (item.order, item.id))] +def agent_operation_names(*, featured_only: bool = False) -> set[str]: + """Return Operations intentionally exposed through Agent Skills. + + The Operation Registry is the governed execution/control plane and includes + UI-only, migration, diagnostic and compatibility Operations. It is not the + same thing as the model-facing Tool surface. + + This helper is the single projection boundary used by CLI discovery: + - all Skill allowlists -> complete Agent Tool surface; + - featured Skills only -> default compact discovery surface. + """ + + names: set[str] = set() + for skill in catalog(): + if featured_only and not bool(skill.get("featured")): + continue + names.update(str(name) for name in skill.get("allowed_tools") or [] if str(name)) + return names + + def registry_snapshot(operation_schemas: list[dict[str, Any]] | None = None) -> dict[str, Any]: skills = catalog() if operation_schemas is not None: diff --git a/backend/app/services/application_action_registry.py b/backend/app/services/application_action_registry.py new file mode 100644 index 00000000..e4881100 --- /dev/null +++ b/backend/app/services/application_action_registry.py @@ -0,0 +1,130 @@ +"""Registry for external application-action connectors. + +Mirrors ``job_sources.router.JobSourceRouter`` but for the *write* plane. +Per ADR-0058, a ``JobSource`` (read/search) never satisfies this protocol — +external write capability is a separate trust boundary behind a protected +Operation + Proposal/HITL. + +Phase 1 registers no real executor: the registry exists so the capability +matrix, idempotency, and audit seams are stable before any CLI / browser +bridge / extension adapter lands. ``list_application_action_connectors`` +surfaces an empty-but-honest matrix rather than pretending a write path +exists. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from typing import Any + +from app.services.application_actions import ( + ApplicationActionConnector, + ApplicationActionStatus, +) + + +@dataclass +class ConnectorCapabilityRow: + """One row of the capability matrix for a registered connector.""" + + source_id: str + status: ApplicationActionStatus + greet: bool + send_message: bool + send_resume: bool + exchange_contact: bool + execution_available: bool + error: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "source_id": self.source_id, + "status": self.status, + "capabilities": { + "greet": self.greet, + "send_message": self.send_message, + "send_resume": self.send_resume, + "exchange_contact": self.exchange_contact, + }, + "execution_available": self.execution_available, + "error": self.error, + } + + +class ApplicationActionConnectorRegistry: + """Holds registered write-plane connectors keyed by ``source_id``.""" + + def __init__(self) -> None: + self._connectors: dict[str, ApplicationActionConnector] = {} + + def register(self, connector: ApplicationActionConnector) -> None: + self._connectors[connector.source_id] = connector + + def unregister(self, source_id: str) -> None: + self._connectors.pop(source_id, None) + + def connectors(self) -> list[ApplicationActionConnector]: + return list(self._connectors.values()) + + def get(self, source_id: str) -> ApplicationActionConnector | None: + return self._connectors.get(source_id) + + async def capability_matrix(self) -> dict[str, Any]: + """Concurrently collect each connector's status + capabilities. + + A connector whose ``status()``/``capabilities()`` raises is reported + UNAVAILABLE with its error, never silently dropped — the matrix is a + diagnostics surface. + """ + + async def _row(conn: ApplicationActionConnector) -> ConnectorCapabilityRow: + try: + status = await asyncio.wait_for(conn.status(), timeout=10.0) + except Exception as exc: # noqa: BLE001 — surface, don't hide + return ConnectorCapabilityRow( + source_id=conn.source_id, + status="UNAVAILABLE", + greet=False, + send_message=False, + send_resume=False, + exchange_contact=False, + execution_available=False, + error=f"{type(exc).__name__}: {exc}", + ) + try: + caps = await asyncio.wait_for(conn.capabilities(), timeout=10.0) + except Exception as exc: # noqa: BLE001 + return ConnectorCapabilityRow( + source_id=conn.source_id, + status=status, + greet=False, + send_message=False, + send_resume=False, + exchange_contact=False, + execution_available=False, + error=f"capabilities: {type(exc).__name__}: {exc}", + ) + return ConnectorCapabilityRow( + source_id=conn.source_id, + status=status, + greet=caps.greet, + send_message=caps.send_message, + send_resume=caps.send_resume, + exchange_contact=caps.exchange_contact, + # Phase 1: no external executor is wired, so nothing may report + # executable even if a connector claims a capability. + execution_available=False, + ) + + rows = await asyncio.gather(*(_row(c) for c in self._connectors.values())) + return { + "schema": "offeru.application_action_capability_matrix.v1", + "execution_plane": "external_write", + "execution_available": False, + "note": "站外写执行器尚未接入;能力矩阵仅声明边界,任何真实写都必须走受保护 Operation + Proposal/HITL。", + "connectors": [r.to_dict() for r in rows], + } + + +application_action_connector_registry = ApplicationActionConnectorRegistry() diff --git a/backend/app/services/application_actions.py b/backend/app/services/application_actions.py index 441a7838..aad664cb 100644 --- a/backend/app/services/application_actions.py +++ b/backend/app/services/application_actions.py @@ -292,3 +292,16 @@ async def preview_application_action( payload["blocking_reasons"] = list(dict.fromkeys(reasons)) payload["state"] = "blocked" return payload + + +async def list_application_action_connectors() -> dict[str, Any]: + """Read-only capability matrix for registered write-plane connectors. + + Phase 1 returns an honest empty/unavailable matrix — no external executor + is wired, so this never claims an executable path exists. + """ + from app.services.application_action_registry import ( + application_action_connector_registry, + ) + + return await application_action_connector_registry.capability_matrix() diff --git a/backend/app/services/legacy_operations.py b/backend/app/services/legacy_operations.py index 52a0c7db..1839b23e 100644 --- a/backend/app/services/legacy_operations.py +++ b/backend/app/services/legacy_operations.py @@ -534,34 +534,6 @@ async def generate_legacy_interview_answer(question_id: int) -> dict[str, Any]: } -async def generate_legacy_cover_letter(job_id: int, resume_id: int) -> dict[str, Any]: - from app.agents.cover_letter import generate_cover_letter - - async with async_session() as db: - job = (await db.execute(select(Job).where(Job.id == job_id))).scalar_one_or_none() - if not job: - raise ValueError("Job not found") - resume = ( - await db.execute(select(Resume).where(Resume.id == resume_id)) - ).scalar_one_or_none() - if not resume: - raise ValueError("Resume not found") - - content = resume.content_json or {} - resume_text = f"姓名: {content.get('name', '')}\n" - resume_text += f"技能: {content.get('skills', '')}\n" - for experience in content.get("experience", []): - resume_text += ( - f"工作经历: {experience.get('company', '')} - " - f"{experience.get('position', '')}\n" - f" 描述: {experience.get('description', '')}\n" - ) - return await generate_cover_letter( - jd=job.raw_description or job.summary, - resume=resume_text, - ) - - async def create_resume_template( name: str, thumbnail_url: str = "", diff --git a/backend/app/ui_cli.py b/backend/app/ui_cli.py new file mode 100644 index 00000000..c617cc44 --- /dev/null +++ b/backend/app/ui_cli.py @@ -0,0 +1,252 @@ +""" +UI Automation CLI — let Agent drive the frontend like a real user. + +Commands: + offeru ui open — navigate to URL + offeru ui click — click element by text + offeru ui fill — fill input by selector + offeru ui eval — execute JavaScript + offeru ui screenshot — take screenshot + offeru ui content — get page text + offeru ui wait — wait for page load + offeru ui batch