From 3a589de1e4aaf34b75bf98d71a523f46245f7479 Mon Sep 17 00:00:00 2001 From: KXH Date: Wed, 12 Aug 2026 02:03:36 +0800 Subject: [PATCH] feat: close FieldPilot 0.6.0 workflow --- .../workflows/amap-provider-validation.yml | 46 ++++ .github/workflows/ci.yml | 1 + README.md | 18 +- backend/.env.example | 3 +- .../versions/20260810_0005_policy_history.py | 83 ++++++ backend/app/api/health.py | 1 + backend/app/api/missions.py | 16 ++ backend/app/config.py | 3 +- backend/app/db/models.py | 57 +++- backend/app/domain/mission.py | 3 + backend/app/domain/planning.py | 1 + backend/app/providers/__init__.py | 8 + backend/app/providers/event_aware.py | 205 ++++++++++++++ backend/app/providers/factory.py | 25 +- backend/app/providers/manual_inventory.py | 156 +++++++++++ backend/app/services/mission_service.py | 179 +++++++++++-- backend/app/services/planning_service.py | 15 +- backend/scripts/smoke_workflow.py | 122 ++++++++- backend/scripts/validate_amap_provider.py | 111 ++++++++ backend/tests/test_api.py | 1 + backend/tests/test_missions_api.py | 54 +++- backend/tests/test_planning_api.py | 249 ++++++++++++++++++ docs/agent-harness.md | 6 +- docs/architecture.md | 9 +- docs/demo-guide.md | 13 +- docs/deployment-free-tier.md | 6 +- docs/development-log.md | 13 + docs/release-readiness.md | 116 ++++---- docs/resume-project-description.md | 4 +- docs/roadmap.md | 36 +-- examples/manual-inventory-v1.json | 70 +++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/App.vue | 176 +++++++++++-- frontend/src/ShowcasePage.vue | 146 +++++++++- frontend/src/services/api.ts | 10 +- frontend/src/styles.css | 78 +++++- frontend/src/types/mission.ts | 3 + frontend/src/types/planning.ts | 1 + render.yaml | 2 +- 40 files changed, 1871 insertions(+), 181 deletions(-) create mode 100644 .github/workflows/amap-provider-validation.yml create mode 100644 backend/alembic/versions/20260810_0005_policy_history.py create mode 100644 backend/app/providers/event_aware.py create mode 100644 backend/app/providers/manual_inventory.py create mode 100644 backend/scripts/validate_amap_provider.py create mode 100644 examples/manual-inventory-v1.json diff --git a/.github/workflows/amap-provider-validation.yml b/.github/workflows/amap-provider-validation.yml new file mode 100644 index 0000000..005a6c2 --- /dev/null +++ b/.github/workflows/amap-provider-validation.yml @@ -0,0 +1,46 @@ +name: fieldpilot-amap-provider-validation + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: backend/requirements.txt + - name: Install backend dependencies + run: python -m pip install -r backend/requirements.txt + - name: Check provider credential + env: + FIELD_PILOT_AMAP_API_KEY: ${{ secrets.FIELD_PILOT_AMAP_API_KEY }} + run: | + if [ -z "$FIELD_PILOT_AMAP_API_KEY" ]; then + echo "Configure repository secret FIELD_PILOT_AMAP_API_KEY before validation." + exit 2 + fi + - name: Run live Amap validation + working-directory: backend + env: + AMAP_API_KEY: ${{ secrets.FIELD_PILOT_AMAP_API_KEY }} + PROVIDER_TIMEOUT_SECONDS: "5" + PROVIDER_MAX_RETRIES: "1" + PROVIDER_MAX_CONCURRENCY: "3" + PROVIDER_MAX_LIVE_CALLS: "12" + run: python scripts/validate_amap_provider.py + - name: Upload sanitized report + if: always() + uses: actions/upload-artifact@v4 + with: + name: amap-provider-validation + path: backend/artifacts/amap-provider-validation.json + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3261a85..43262e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,3 +55,4 @@ jobs: run: | npm ci npm run build + npm run build:showcase diff --git a/README.md b/README.md index b2a004a..885bb5f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ FieldPilot 面向经常跨省市出差的外勤人员,把口语描述中的地 **在线项目站:** [fieldpilot-kxh.netlify.app](https://fieldpilot-kxh.netlify.app/) · **在线工作台:** [fieldpilot-kxh.netlify.app/workbench](https://fieldpilot-kxh.netlify.app/workbench) · **源代码:** [github.com/KXHXK/fieldpilot](https://github.com/KXHXK/fieldpilot) -当前开发版本是 `0.5.0-dev`。类型化语义 Agent Harness 负责把自然语言安全转换为严格 MissionDraft,并治理模型契约、调用预算、确定性后置校验、幂等、审计与 Eval;确定性 Planner、Policy Engine 和独立 Verifier 负责时窗、候选、费用与报销判断。系统不会让模型编造车次、计算成本或执行购票订房。 +当前仓库发布版本是 `0.6.0`。类型化语义 Agent Harness 负责把自然语言安全转换为严格 MissionDraft,并治理模型契约、调用预算、确定性后置校验、幂等、审计与 Eval;确定性 Planner、Policy Engine 和独立 Verifier 负责时窗、候选、费用与报销判断。系统不会让模型编造车次、计算成本或执行购票订房。 > `0.1.0` 是已提交、可回退的技术基线,不是最终求职版本。目标 `v1.0` 将围绕真实跨城出差、任务时窗、报销约束和动态重规划重构;完整设计见 [企业级目标设计](docs/specs/2026-07-30-fieldpilot-enterprise-design.md)。在对应实现、评测和部署证据完成前,目标设计中的能力不得写成已落地事实。 @@ -18,7 +18,7 @@ FieldPilot 面向经常跨省市出差的外勤人员,把口语描述中的地 自然语言输入 -> Strict Contract -> PydanticAI MissionDraft -> Deterministic Guard -> AgentRun -> 澄清 / 用户确认 -> Mission + VisitTask + ExpensePolicy 持久化 --> Candidate Provider(高德路线/餐饮 POI live/mixed 或显式 Fixture) +-> Candidate Provider(高德路线/餐饮、授权人工库存或显式 Fixture) -> PolicyEngine -> Bounded Planner -> Independent Verifier -> PlanRevision / ProviderSnapshot -> Vue 时间线与来源展示 -> ExecutionCheckpoint 锁定/完成已执行前缀 @@ -34,7 +34,9 @@ FieldPilot 面向经常跨省市出差的外勤人员,把口语描述中的地 - 高德 v3 地理编码与 v5 市内路线/周边餐饮 POI 适配,具备异步并发、超时、有限重试、调用预算、缓存和逐能力降级。 - Planner 在工作点、交通枢纽或酒店附近的可用缓冲中安排餐次;只采用带人均消费且不超过剩余餐补的候选,Policy Engine 按自然日核算,Verifier 复查餐次、锚点、时间窗和费用。 - 计划请求、Agent 请求与业务事件分别幂等;active revision 使用乐观并发控制。 -- 任务改期/取消/新增/延长、预算和偏好事件可原子应用;天气与交通中断在尚未接入规划过滤前明确标记 `recorded_only`。 +- 任务改期/取消/新增/延长、预算和偏好事件可原子应用;交通取消/不可用会排除候选,延误会平移时间并降低可靠性,高/中天气风险会分别过滤受影响任务的步行与骑行/骑行候选。 +- 报销政策使用追加式不可变版本链;预算事件创建新快照,历史版本不覆盖,PlanRevision 绑定实际使用的 `policy_snapshot_id`。 +- 铁路、航班与酒店支持严格 JSON Schema 的授权人工候选导入,强制标记 `manual` 并保存内容指纹;不抓取 12306 内部接口。 - 执行检查点命令使用 command ID 幂等与版本号并发控制;锁定/完成位置只能单调前进,重规划会逐段保留已执行前缀并从检查点恢复后缀求解,Verifier 再独立校验边界。 - Vue 3 + TypeScript 工作台展示 Agent trace、候选比较、执行时间线、政策判定、来源快照和重规划差异。 - SQLite 本地验证与 PostgreSQL Compose 交付配置;Alembic 迁移、健康/就绪检查和 GitHub Actions CI。 @@ -49,11 +51,12 @@ FieldPilot 面向经常跨省市出差的外勤人员,把口语描述中的地 | 高德 v5 市内路线适配 | 已进入规划链路并完成 MockTransport 契约/故障测试;真实密钥未复验 | 已验证适配与降级,未验证实时服务可用性 | | 高德 v5 周边餐饮 POI | 已实现预算过滤、缓存、失败降级和来源快照;真实密钥未复验 | 无人均消费字段的 POI 不进入方案,Fixture 不冒充实时报价 | | Vue v1 任务、方案、来源与重规划工作台 | 已实现、生产构建并部署 | 本地完整链路与公网 Agent 解析/方案创建已用真实浏览器验收 | -| Mission、政策快照与计划修订持久化 | 已实现并测试 | SQLite 与 Neon PostgreSQL 已验证;Render 重启后数据仍可读取 | +| Mission、不可变政策版本与计划修订持久化 | 已实现并测试 | 预算变更追加快照且历史不覆盖;SQLite 迁移已验证,Neon `0005` 待本次发布后迁移 | | 有限搜索 Planner + Policy Engine + 独立 Verifier | 已实现;跨城、酒店和无 Key 餐饮使用明确 Fixture | 确定性规划可复现,数据模式必须随 segment 传递 | | 计划请求幂等、revision 冲突与激活 | 已实现并测试 | 并发与重放路径已有接口测试 | | AgentRun 审计、幂等与固定集 | 已实现输入指纹、trace 查询、5 场景 Mock 基线与独立 15 场景 live 固定集 | Mock 与 live 指标、数据集和报告分离 | -| ReplanEvent 事实应用与 Revision Diff | 已实现并测试;外部风险信号仅 recorded_only | 不声称所有中断已自动处置 | +| ReplanEvent 事实应用与 Revision Diff | 已实现并测试;交通中断与天气风险进入确定性候选过滤并保存过滤快照 | 仅处理明确事件载荷,不声称已订阅实时天气或运营中断流 | +| 授权人工库存导入 | 已实现铁路/航班/酒店 JSON Schema、来源强制标记与内容指纹 | 候选由用户或授权系统提供,不代表自动同步实时库存 | | ExecutionCheckpoint 与严格后缀重规划 | 已实现命令幂等、版本冲突、单调锁定/完成和前缀逐段一致性校验 | 只重算检查点后的可变后缀;有界搜索不声称全局最优 | | CrewAI、LlamaIndex、Pydantic Evals | 未接入 | 当前业务不需要 | | RAG、审批流、SSE、全局路线最优化 | 未实现 | 不属于当前已验收能力 | @@ -114,6 +117,7 @@ docker compose up --build USE_MOCK_LLM=false AMAP_API_KEY= LOCAL_ROUTE_PROVIDER=amap +MANUAL_CANDIDATE_FILE=../examples/manual-inventory-v1.json PROVIDER_TIMEOUT_SECONDS=3.0 PROVIDER_MAX_RETRIES=1 PROVIDER_MAX_CONCURRENCY=4 @@ -130,7 +134,7 @@ VITE_AMAP_JS_KEY= VITE_AMAP_JS_SECURITY_CODE= ``` -后端 `AMAP_API_KEY` 与浏览器 `VITE_AMAP_JS_KEY` 不是同一种 Key。`LOCAL_ROUTE_PROVIDER=fixture` 完全离线;设为 `amap` 后按路线查询真实接口,失败会保留原因并逐项降级。真实密钥完成复验前,不应声称线上可用。 +后端 `AMAP_API_KEY` 与浏览器 `VITE_AMAP_JS_KEY` 不是同一种 Key。`LOCAL_ROUTE_PROVIDER=fixture` 完全离线;设为 `amap` 后按路线查询真实接口,失败会保留原因并逐项降级。`MANUAL_CANDIDATE_FILE` 可选,只接受本地严格 JSON 文件并覆盖跨城交通/住宿候选;示例见 [`examples/manual-inventory-v1.json`](examples/manual-inventory-v1.json)。真实密钥完成复验前,不应声称线上高德可用。 ## 验证 @@ -142,7 +146,7 @@ cd ..\frontend npm run build ``` -当前结果(2026-08-02):后端 `51 passed`;Neon PostgreSQL 已执行 Alembic 至 `20260731_0004 (head)`;完整工作台与项目专题两种前端生产构建成功;Kimi K2.6 的 15 场景最终全量 run 为 15/15 live、状态与安全标签准确率 100%、选定字段精确率 94.87%、澄清字段精确率 93.33%。本地链路、Render 公网 R1/R2 smoke、重启后持久化、生产 CORS、Netlify HTTP/CDN 和公网真实浏览器链路均通过。详细记录见 [开发日志](docs/development-log.md)。 +当前结果(2026-08-10):后端 `55 passed`;本地 SQLite 已完成 Alembic `20260810_0005 (head)` 升级、schema check、回退与重升;真实 Uvicorn HTTP 进程完成 R1~R5 smoke,覆盖任务改期、预算快照、交通取消、天气风险和严格前缀保护;完整工作台与项目专题两种前端生产构建成功。Kimi K2.6 的既有 15 场景最终全量 run 为 15/15 live、状态与安全标签准确率 100%、选定字段精确率 94.87%、澄清字段精确率 93.33%,每个代码版本每场景调用一次,不声明跨重复稳定率。上一版公网链路已验证;`0.6.0` 的 Neon/Render/Netlify 生产发布需在合并审批后执行。详细记录见 [开发日志](docs/development-log.md)。 运行中的完整 HTTP 冒烟(需要先启动后端): diff --git a/backend/.env.example b/backend/.env.example index 51aaf12..6ca1e0f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,8 +1,9 @@ -APP_VERSION=0.5.0-dev +APP_VERSION=0.6.0 USE_MOCK_LLM=true AMAP_API_KEY= AMAP_BASE_URL=https://restapi.amap.com LOCAL_ROUTE_PROVIDER=fixture +MANUAL_CANDIDATE_FILE= PROVIDER_TIMEOUT_SECONDS=3.0 PROVIDER_MAX_RETRIES=1 PROVIDER_MAX_CONCURRENCY=4 diff --git a/backend/alembic/versions/20260810_0005_policy_history.py b/backend/alembic/versions/20260810_0005_policy_history.py new file mode 100644 index 0000000..25d76dd --- /dev/null +++ b/backend/alembic/versions/20260810_0005_policy_history.py @@ -0,0 +1,83 @@ +"""add immutable expense policy history + +Revision ID: 20260810_0005 +Revises: 20260731_0004 +Create Date: 2026-08-10 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260810_0005" +down_revision: str | None = "20260731_0004" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "expense_policy_versions", + sa.Column("snapshot_id", sa.String(length=40), nullable=False), + sa.Column("mission_id", sa.String(length=40), nullable=False), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("based_on_snapshot_id", sa.String(length=40), nullable=True), + sa.Column("source_event_id", sa.String(length=120), nullable=True), + sa.Column("policy_id", sa.String(length=80), nullable=False), + sa.Column("policy_version", sa.String(length=40), nullable=False), + sa.Column("allowed_rail_classes", sa.JSON(), nullable=False), + sa.Column("allowed_flight_classes", sa.JSON(), nullable=False), + sa.Column("hotel_nightly_cap_yuan", sa.Integer(), nullable=False), + sa.Column("meal_daily_cap_yuan", sa.Integer(), nullable=False), + sa.Column("local_transport_daily_cap_yuan", sa.Integer(), nullable=False), + sa.Column("trip_total_cap_yuan", sa.Integer(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["mission_id"], ["missions.mission_id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("snapshot_id"), + sa.UniqueConstraint( + "mission_id", "sequence", name="uq_expense_policy_version_sequence" + ), + sa.UniqueConstraint( + "source_event_id", name="uq_expense_policy_source_event" + ), + ) + op.create_index( + "ix_expense_policy_versions_mission_id", + "expense_policy_versions", + ["mission_id"], + ) + op.execute( + sa.text( + """ + INSERT INTO expense_policy_versions ( + snapshot_id, mission_id, sequence, based_on_snapshot_id, + source_event_id, policy_id, policy_version, + allowed_rail_classes, allowed_flight_classes, + hotel_nightly_cap_yuan, meal_daily_cap_yuan, + local_transport_daily_cap_yuan, trip_total_cap_yuan, created_at + ) + SELECT + snapshot_id, mission_id, 1, NULL, NULL, policy_id, policy_version, + allowed_rail_classes, allowed_flight_classes, + hotel_nightly_cap_yuan, meal_daily_cap_yuan, + local_transport_daily_cap_yuan, trip_total_cap_yuan, created_at + FROM expense_policy_snapshots + """ + ) + ) + + +def downgrade() -> None: + op.drop_index( + "ix_expense_policy_versions_mission_id", + table_name="expense_policy_versions", + ) + op.drop_table("expense_policy_versions") diff --git a/backend/app/api/health.py b/backend/app/api/health.py index 7b136c5..2200ac3 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -30,4 +30,5 @@ async def readiness_check( "local_route_provider": settings.local_route_provider, "amap_key_configured": bool(settings.amap_api_key), "llm_key_configured": bool(settings.openai_api_key), + "manual_inventory_configured": bool(settings.manual_candidate_file), } diff --git a/backend/app/api/missions.py b/backend/app/api/missions.py index 9c00a19..44e6f43 100644 --- a/backend/app/api/missions.py +++ b/backend/app/api/missions.py @@ -6,6 +6,7 @@ ActivateRevisionRequest, ExecutionCheckpointCommand, ExecutionCheckpointRead, + ExpensePolicyRead, MissionCreate, MissionRead, PlanGenerationRequest, @@ -29,6 +30,7 @@ RevisionConflictError, create_mission, get_mission, + list_expense_policy_versions, record_replan_event, ) from app.services.planning_service import ( @@ -66,6 +68,20 @@ async def get_mission_endpoint( raise HTTPException(status_code=404, detail="任务不存在") from exc +@router.get( + "/{mission_id}/expense-policy/versions", + response_model=list[ExpensePolicyRead], +) +async def list_expense_policy_versions_endpoint( + mission_id: str, + session: AsyncSession = Depends(get_db_session), +) -> list[ExpensePolicyRead]: + try: + return await list_expense_policy_versions(session, mission_id) + except MissionNotFoundError as exc: + raise HTTPException(status_code=404, detail="任务不存在") from exc + + @router.get( "/{mission_id}/execution", response_model=ExecutionCheckpointRead, diff --git a/backend/app/config.py b/backend/app/config.py index 910077e..e6ec75b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -31,11 +31,12 @@ def normalize_database_url(value: str) -> str: class Settings(BaseSettings): app_name: str = "FieldPilot API" - app_version: str = "0.5.0-dev" + app_version: str = "0.6.0" use_mock_llm: bool = True amap_api_key: str = "" amap_base_url: str = "https://restapi.amap.com" local_route_provider: Literal["fixture", "amap"] = "fixture" + manual_candidate_file: str = "" provider_timeout_seconds: float = Field(default=3.0, gt=0, le=30) provider_max_retries: int = Field(default=1, ge=0, le=3) provider_max_concurrency: int = Field(default=4, ge=1, le=10) diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 85e056d..2810e6d 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -54,12 +54,18 @@ class MissionRecord(Base): lazy="selectin", order_by="VisitTaskRecord.position", ) - expense_policy: Mapped[ExpensePolicySnapshotRecord] = relationship( + legacy_expense_policy_projection: Mapped[ExpensePolicySnapshotRecord] = relationship( back_populates="mission", cascade="all, delete-orphan", lazy="selectin", uselist=False, ) + expense_policy_versions: Mapped[list[ExpensePolicyVersionRecord]] = relationship( + back_populates="mission", + cascade="all, delete-orphan", + lazy="selectin", + order_by="ExpensePolicyVersionRecord.sequence", + ) revisions: Mapped[list[PlanRevisionRecord]] = relationship( back_populates="mission", cascade="all, delete-orphan", @@ -87,6 +93,18 @@ class MissionRecord(Base): lazy="selectin", ) + @property + def expense_policy(self) -> ExpensePolicyVersionRecord | ExpensePolicySnapshotRecord: + """Return the immutable active policy snapshot. + + ``expense_policy_snapshots`` is retained as the legacy initial projection so + existing databases can migrate without rewriting the original row. New + planning reads use the latest append-only version. + """ + if self.expense_policy_versions: + return self.expense_policy_versions[-1] + return self.legacy_expense_policy_projection + class VisitTaskRecord(Base): __tablename__ = "visit_tasks" @@ -136,7 +154,42 @@ class ExpensePolicySnapshotRecord(Base): DateTime(timezone=True), default=utc_now ) - mission: Mapped[MissionRecord] = relationship(back_populates="expense_policy") + mission: Mapped[MissionRecord] = relationship( + back_populates="legacy_expense_policy_projection" + ) + + +class ExpensePolicyVersionRecord(Base): + __tablename__ = "expense_policy_versions" + __table_args__ = ( + UniqueConstraint( + "mission_id", "sequence", name="uq_expense_policy_version_sequence" + ), + UniqueConstraint("source_event_id", name="uq_expense_policy_source_event"), + ) + + snapshot_id: Mapped[str] = mapped_column(String(40), primary_key=True) + mission_id: Mapped[str] = mapped_column( + ForeignKey("missions.mission_id", ondelete="CASCADE"), index=True + ) + sequence: Mapped[int] = mapped_column(Integer) + based_on_snapshot_id: Mapped[str | None] = mapped_column(String(40), nullable=True) + source_event_id: Mapped[str | None] = mapped_column(String(120), nullable=True) + policy_id: Mapped[str] = mapped_column(String(80)) + policy_version: Mapped[str] = mapped_column(String(40)) + allowed_rail_classes: Mapped[list[str]] = mapped_column(JSON) + allowed_flight_classes: Mapped[list[str]] = mapped_column(JSON) + hotel_nightly_cap_yuan: Mapped[int] = mapped_column(Integer) + meal_daily_cap_yuan: Mapped[int] = mapped_column(Integer) + local_transport_daily_cap_yuan: Mapped[int] = mapped_column(Integer) + trip_total_cap_yuan: Mapped[int] = mapped_column(Integer) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utc_now + ) + + mission: Mapped[MissionRecord] = relationship( + back_populates="expense_policy_versions" + ) class PlanRevisionRecord(Base): diff --git a/backend/app/domain/mission.py b/backend/app/domain/mission.py index fb01f93..ed7bcc0 100644 --- a/backend/app/domain/mission.py +++ b/backend/app/domain/mission.py @@ -181,6 +181,9 @@ class VisitTaskRead(StrictModel): class ExpensePolicyRead(ExpensePolicyInput): snapshot_id: str + snapshot_sequence: int = Field(default=1, ge=1) + based_on_snapshot_id: str | None = None + source_event_id: str | None = None class MissionRead(StrictModel): diff --git a/backend/app/domain/planning.py b/backend/app/domain/planning.py index 1bafd5f..ee4b0ee 100644 --- a/backend/app/domain/planning.py +++ b/backend/app/domain/planning.py @@ -204,6 +204,7 @@ class PlanBundle(StrictModel): mission_id: str preferred_option_id: str options: list[PlanOption] = Field(min_length=1, max_length=3) + policy_snapshot_id: str | None = None provider_snapshot_ids: list[str] generated_at: datetime planner_version: str diff --git a/backend/app/providers/__init__.py b/backend/app/providers/__init__.py index 54f1404..a6bcbd1 100644 --- a/backend/app/providers/__init__.py +++ b/backend/app/providers/__init__.py @@ -1,12 +1,20 @@ from app.providers.amap import AmapLocalRouteProvider, ProviderFailure from app.providers.base import CandidateProvider, ProviderSnapshotData +from app.providers.event_aware import EventAwareCandidateProvider from app.providers.factory import create_candidate_provider from app.providers.fixture import FixtureCandidateProvider +from app.providers.manual_inventory import ( + ManualInventoryCandidateProvider, + ManualInventoryImport, +) __all__ = [ "AmapLocalRouteProvider", "CandidateProvider", + "EventAwareCandidateProvider", "FixtureCandidateProvider", + "ManualInventoryCandidateProvider", + "ManualInventoryImport", "ProviderFailure", "ProviderSnapshotData", "create_candidate_provider", diff --git a/backend/app/providers/event_aware.py b/backend/app/providers/event_aware.py new file mode 100644 index 0000000..d374fbc --- /dev/null +++ b/backend/app/providers/event_aware.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timedelta, timezone +from typing import Any + +from app.domain import ( + CandidateBundle, + LocationInput, + MealCandidate, + MealType, + MissionRead, + ReplanEventType, + SourceMode, + TransportCandidate, + TransportMode, +) +from app.providers.base import CandidateProvider, ProviderSnapshotData + + +class EventAwareCandidateProvider: + """Apply a typed replan event as a deterministic provider boundary. + + The decorator keeps external adapters unaware of mission execution state while + ensuring disruption and weather facts affect both intercity candidates and the + local routes fetched lazily by the planner. + """ + + supported_event_types = { + ReplanEventType.TRANSPORT_DISRUPTION.value, + ReplanEventType.WEATHER_RISK.value, + } + + def __init__( + self, + delegate: CandidateProvider, + *, + event_id: str, + event_type: str, + payload: dict[str, Any], + ) -> None: + if event_type not in self.supported_event_types: + raise ValueError(f"unsupported candidate event: {event_type}") + self.delegate = delegate + self.event_id = event_id + self.event_type = event_type + self.payload = payload + self.provider_name = delegate.provider_name + self._removed_candidate_ids: set[str] = set() + self._delayed_candidate_ids: set[str] = set() + self._filtered_modes: set[str] = set() + + def search(self, mission: MissionRead) -> CandidateBundle: + bundle = self.delegate.search(mission) + outbound = self._apply_transport_event(bundle.outbound) + returns = self._apply_transport_event(bundle.returns) + assumptions = [*bundle.assumptions, self._assumption()] + return bundle.model_copy( + update={"outbound": outbound, "returns": returns, "assumptions": assumptions} + ) + + async def local_routes( + self, + from_ref: str, + to_ref: str, + from_location: LocationInput, + to_location: LocationInput, + depart_at: datetime, + preferred_modes: list[str], + timezone_name: str = "Asia/Shanghai", + ) -> list[TransportCandidate]: + routes = await self.delegate.local_routes( + from_ref, + to_ref, + from_location, + to_location, + depart_at, + preferred_modes, + timezone_name, + ) + routes = self._apply_transport_event(routes) + if self.event_type == ReplanEventType.WEATHER_RISK.value: + routes = self._apply_weather_event(routes, from_ref, to_ref) + return routes + + async def nearby_meals( + self, + anchor_ref: str, + anchor_location: LocationInput, + meal_type: MealType, + max_cost_yuan: int, + ) -> list[MealCandidate]: + return await self.delegate.nearby_meals( + anchor_ref, anchor_location, meal_type, max_cost_yuan + ) + + def provider_snapshots(self) -> list[ProviderSnapshotData]: + fingerprint = hashlib.sha256( + json.dumps( + { + "event_id": self.event_id, + "event_type": self.event_type, + "payload": self.payload, + }, + ensure_ascii=False, + sort_keys=True, + ).encode("utf-8") + ).hexdigest() + return [ + *self.delegate.provider_snapshots(), + ProviderSnapshotData( + provider="fieldpilot-event-filter-v1", + capability="event_candidate_filter", + source_mode=SourceMode.MANUAL, + query_fingerprint=fingerprint, + payload={ + "event_id": self.event_id, + "event_type": self.event_type, + "rule": self._assumption(), + "removed_candidate_ids": sorted(self._removed_candidate_ids), + "delayed_candidate_ids": sorted(self._delayed_candidate_ids), + "filtered_modes": sorted(self._filtered_modes), + }, + fetched_at=datetime.now(timezone.utc), + ), + ] + + async def aclose(self) -> None: + await self.delegate.aclose() + + def _apply_transport_event( + self, candidates: list[TransportCandidate] + ) -> list[TransportCandidate]: + if self.event_type != ReplanEventType.TRANSPORT_DISRUPTION.value: + return candidates + target = str(self.payload["candidate_id"]) + status = str(self.payload["status"]) + delay_minutes = int(self.payload.get("estimated_delay_minutes") or 0) + result: list[TransportCandidate] = [] + for candidate in candidates: + if candidate.candidate_id != target: + result.append(candidate) + continue + if status in {"cancelled", "unavailable"}: + self._removed_candidate_ids.add(candidate.candidate_id) + continue + delay = timedelta(minutes=delay_minutes) + metadata = { + **candidate.metadata, + "replan_event_id": self.event_id, + "disruption_status": status, + "estimated_delay_minutes": delay_minutes, + } + self._delayed_candidate_ids.add(candidate.candidate_id) + result.append( + candidate.model_copy( + update={ + "depart_at": candidate.depart_at + delay, + "arrive_at": candidate.arrive_at + delay, + "reliability_score": max(0, candidate.reliability_score - 25), + "metadata": metadata, + } + ) + ) + return result + + def _apply_weather_event( + self, + candidates: list[TransportCandidate], + from_ref: str, + to_ref: str, + ) -> list[TransportCandidate]: + affected = set(self.payload.get("affected_task_ids") or []) + if affected and not ({from_ref, to_ref} & affected): + return candidates + severity = str(self.payload["severity"]) + blocked_modes = ( + {TransportMode.WALKING, TransportMode.BICYCLING} + if severity == "high" + else {TransportMode.BICYCLING} + ) + result = [] + for candidate in candidates: + if candidate.mode in blocked_modes: + self._removed_candidate_ids.add(candidate.candidate_id) + self._filtered_modes.add(candidate.mode.value) + continue + result.append(candidate) + return result + + def _assumption(self) -> str: + if self.event_type == ReplanEventType.TRANSPORT_DISRUPTION.value: + status = self.payload["status"] + candidate_id = self.payload["candidate_id"] + if status == "delayed": + delay = int(self.payload.get("estimated_delay_minutes") or 0) + return f"事件 {self.event_id} 将候选 {candidate_id} 延后 {delay} 分钟并降低可靠性。" + return f"事件 {self.event_id} 从本轮规划排除候选 {candidate_id}({status})。" + severity = self.payload["severity"] + modes = "步行和骑行" if severity == "high" else "骑行" + return f"事件 {self.event_id} 对受影响任务过滤{modes}候选(天气风险 {severity})。" + + +__all__ = ["EventAwareCandidateProvider"] diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py index 756e057..e50474d 100644 --- a/backend/app/providers/factory.py +++ b/backend/app/providers/factory.py @@ -4,21 +4,26 @@ from app.providers.amap import AmapLocalRouteProvider from app.providers.base import CandidateProvider from app.providers.fixture import FixtureCandidateProvider +from app.providers.manual_inventory import ManualInventoryCandidateProvider def create_candidate_provider(settings: Settings) -> CandidateProvider: fixture = FixtureCandidateProvider() if settings.local_route_provider == "fixture": - return fixture - return AmapLocalRouteProvider( - api_key=settings.amap_api_key, - fallback=fixture, - base_url=settings.amap_base_url, - timeout_seconds=settings.provider_timeout_seconds, - max_retries=settings.provider_max_retries, - max_concurrency=settings.provider_max_concurrency, - max_live_calls=settings.provider_max_live_calls, - ) + provider: CandidateProvider = fixture + else: + provider = AmapLocalRouteProvider( + api_key=settings.amap_api_key, + fallback=fixture, + base_url=settings.amap_base_url, + timeout_seconds=settings.provider_timeout_seconds, + max_retries=settings.provider_max_retries, + max_concurrency=settings.provider_max_concurrency, + max_live_calls=settings.provider_max_live_calls, + ) + if settings.manual_candidate_file: + return ManualInventoryCandidateProvider(provider, settings.manual_candidate_file) + return provider __all__ = ["create_candidate_provider"] diff --git a/backend/app/providers/manual_inventory.py b/backend/app/providers/manual_inventory.py new file mode 100644 index 0000000..d3e7720 --- /dev/null +++ b/backend/app/providers/manual_inventory.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import hashlib +from datetime import datetime, timezone +from pathlib import Path + +from pydantic import Field, model_validator + +from app.domain import ( + CandidateBundle, + LocationInput, + MealCandidate, + MealType, + MissionRead, + SourceMode, + StayCandidate, + TransportCandidate, +) +from app.domain.mission import StrictModel +from app.providers.base import CandidateProvider, ProviderSnapshotData + + +class ManualInventoryImport(StrictModel): + import_id: str = Field(min_length=8, max_length=120) + source_label: str = Field(min_length=1, max_length=120) + outbound: list[TransportCandidate] = Field(min_length=1, max_length=50) + returns: list[TransportCandidate] = Field(min_length=1, max_length=50) + stays: list[StayCandidate] = Field(default_factory=list, max_length=50) + notes: str = Field(default="", max_length=500) + + @model_validator(mode="after") + def validate_directions(self) -> ManualInventoryImport: + if any(item.direction != "outbound" for item in self.outbound): + raise ValueError("outbound 候选的 direction 必须为 outbound") + if any(item.direction != "return" for item in self.returns): + raise ValueError("returns 候选的 direction 必须为 return") + return self + + +class ManualInventoryCandidateProvider: + """Overlay authorized rail/flight/hotel candidates on a local-route provider.""" + + def __init__(self, delegate: CandidateProvider, import_file: str) -> None: + self.delegate = delegate + self.import_path = Path(import_file).expanduser().resolve() + self.provider_name = f"manual-inventory+{delegate.provider_name}" + self._snapshot: ProviderSnapshotData | None = None + + def search(self, mission: MissionRead) -> CandidateBundle: + raw = self.import_path.read_bytes() + imported = ManualInventoryImport.model_validate_json(raw) + base = self.delegate.search(mission) + provider = f"manual:{imported.source_label}" + outbound = [self._manual_transport(item, provider) for item in imported.outbound] + returns = [self._manual_transport(item, provider) for item in imported.returns] + stays = [self._manual_stay(item, provider) for item in imported.stays] + reference_locations = { + **base.reference_locations, + **{ + f"hotel:{stay.candidate_id}": LocationInput( + name=stay.name, + address=stay.address, + city=stay.city, + ) + for stay in stays + }, + } + fingerprint = hashlib.sha256(raw).hexdigest() + self._snapshot = ProviderSnapshotData( + provider=provider, + capability="manual_intercity_and_stay_inventory", + source_mode=SourceMode.MANUAL, + query_fingerprint=fingerprint, + payload={ + "import_id": imported.import_id, + "source_label": imported.source_label, + "outbound_count": len(outbound), + "return_count": len(returns), + "stay_count": len(stays), + "notes": imported.notes, + "content_sha256": fingerprint, + }, + fetched_at=datetime.now(timezone.utc), + ) + return base.model_copy( + update={ + "provider": self.provider_name, + "source_mode": SourceMode.MIXED, + "query_fingerprint": hashlib.sha256( + f"{base.query_fingerprint}:{fingerprint}".encode("utf-8") + ).hexdigest(), + "outbound": outbound, + "returns": returns, + "stays": stays, + "reference_locations": reference_locations, + "assumptions": [ + *base.assumptions, + f"跨城交通和住宿来自人工导入 {imported.import_id},来源标记为 manual。", + ], + } + ) + + async def local_routes( + self, + from_ref: str, + to_ref: str, + from_location: LocationInput, + to_location: LocationInput, + depart_at: datetime, + preferred_modes: list[str], + timezone_name: str = "Asia/Shanghai", + ) -> list[TransportCandidate]: + return await self.delegate.local_routes( + from_ref, + to_ref, + from_location, + to_location, + depart_at, + preferred_modes, + timezone_name, + ) + + async def nearby_meals( + self, + anchor_ref: str, + anchor_location: LocationInput, + meal_type: MealType, + max_cost_yuan: int, + ) -> list[MealCandidate]: + return await self.delegate.nearby_meals( + anchor_ref, anchor_location, meal_type, max_cost_yuan + ) + + def provider_snapshots(self) -> list[ProviderSnapshotData]: + snapshots = self.delegate.provider_snapshots() + return [*snapshots, *([self._snapshot] if self._snapshot is not None else [])] + + async def aclose(self) -> None: + await self.delegate.aclose() + + @staticmethod + def _manual_transport( + candidate: TransportCandidate, provider: str + ) -> TransportCandidate: + return candidate.model_copy( + update={"provider": provider, "source_mode": SourceMode.MANUAL} + ) + + @staticmethod + def _manual_stay(candidate: StayCandidate, provider: str) -> StayCandidate: + return candidate.model_copy( + update={"provider": provider, "source_mode": SourceMode.MANUAL} + ) + + +__all__ = ["ManualInventoryCandidateProvider", "ManualInventoryImport"] diff --git a/backend/app/services/mission_service.py b/backend/app/services/mission_service.py index 06a116b..5412b57 100644 --- a/backend/app/services/mission_service.py +++ b/backend/app/services/mission_service.py @@ -11,7 +11,9 @@ from app.db.models import ( ExpensePolicySnapshotRecord, + ExpensePolicyVersionRecord, MissionRecord, + PlanRevisionRecord, ReplanEventRecord, VisitTaskRecord, ) @@ -37,6 +39,8 @@ TaskCancelledPayload, TaskExtendedPayload, TaskRescheduledPayload, + TransportDisruptionPayload, + WeatherRiskPayload, ) @@ -77,7 +81,9 @@ def _mission_query(mission_id: str): .where(MissionRecord.mission_id == mission_id) .options( selectinload(MissionRecord.visits), - selectinload(MissionRecord.expense_policy), + selectinload(MissionRecord.legacy_expense_policy_projection), + selectinload(MissionRecord.expense_policy_versions), + selectinload(MissionRecord.execution_checkpoint), ) ) @@ -125,8 +131,9 @@ async def create_mission( for position, visit in enumerate(command.visits, start=1) ] policy = command.expense_policy - mission.expense_policy = ExpensePolicySnapshotRecord( - snapshot_id=_new_id("pol"), + initial_snapshot_id = _new_id("pol") + mission.legacy_expense_policy_projection = ExpensePolicySnapshotRecord( + snapshot_id=initial_snapshot_id, mission_id=mission_id, policy_id=policy.policy_id, policy_version=policy.policy_version, @@ -137,6 +144,23 @@ async def create_mission( local_transport_daily_cap_yuan=policy.local_transport_daily_cap_yuan, trip_total_cap_yuan=policy.trip_total_cap_yuan, ) + mission.expense_policy_versions = [ + ExpensePolicyVersionRecord( + snapshot_id=initial_snapshot_id, + mission_id=mission_id, + sequence=1, + based_on_snapshot_id=None, + source_event_id=None, + policy_id=policy.policy_id, + policy_version=policy.policy_version, + allowed_rail_classes=policy.allowed_rail_classes, + allowed_flight_classes=policy.allowed_flight_classes, + hotel_nightly_cap_yuan=policy.hotel_nightly_cap_yuan, + meal_daily_cap_yuan=policy.meal_daily_cap_yuan, + local_transport_daily_cap_yuan=policy.local_transport_daily_cap_yuan, + trip_total_cap_yuan=policy.trip_total_cap_yuan, + ) + ] session.add(mission) await session.commit() return await get_mission(session, mission_id) @@ -150,6 +174,20 @@ async def get_mission(session: AsyncSession, mission_id: str) -> MissionRead: return _to_mission_read(mission) +async def list_expense_policy_versions( + session: AsyncSession, mission_id: str +) -> list[ExpensePolicyRead]: + mission = await session.get(MissionRecord, mission_id) + if mission is None: + raise MissionNotFoundError(mission_id) + result = await session.execute( + select(ExpensePolicyVersionRecord) + .where(ExpensePolicyVersionRecord.mission_id == mission_id) + .order_by(ExpensePolicyVersionRecord.sequence) + ) + return [_to_policy_read(item) for item in result.scalars().all()] + + async def record_replan_event( session: AsyncSession, mission_id: str, @@ -363,18 +401,118 @@ async def _apply_event( elif event_type == ReplanEventType.BUDGET_CHANGED: payload = BudgetChangedPayload.model_validate(command.payload) policy = mission.expense_policy + next_values = { + "policy_id": policy.policy_id, + "policy_version": policy.policy_version, + "allowed_rail_classes": list(policy.allowed_rail_classes), + "allowed_flight_classes": list(policy.allowed_flight_classes), + "hotel_nightly_cap_yuan": policy.hotel_nightly_cap_yuan, + "meal_daily_cap_yuan": policy.meal_daily_cap_yuan, + "local_transport_daily_cap_yuan": policy.local_transport_daily_cap_yuan, + "trip_total_cap_yuan": policy.trip_total_cap_yuan, + } for field, after in payload.model_dump().items(): if after is None: continue - before = getattr(policy, field) - setattr(policy, field, after) + before = next_values[field] + next_values[field] = after changes.append(_change(f"expense_policy.{field}", before, after)) + next_snapshot = ExpensePolicyVersionRecord( + snapshot_id=_new_id("pol"), + mission_id=mission.mission_id, + sequence=getattr(policy, "sequence", 1) + 1, + based_on_snapshot_id=policy.snapshot_id, + source_event_id=command.event_id, + **next_values, + ) + mission.expense_policy_versions.append(next_snapshot) + changes.append( + _change( + "expense_policy.snapshot_id", + policy.snapshot_id, + next_snapshot.snapshot_id, + ) + ) elif event_type == ReplanEventType.PREFERENCE_CHANGED: payload = PreferenceChangedPayload.model_validate(command.payload) before = mission.transport_preferences after = payload.transport_preferences.model_dump(mode="json") mission.transport_preferences = after changes.append(_change("transport_preferences", before, after)) + elif event_type == ReplanEventType.TRANSPORT_DISRUPTION: + payload = TransportDisruptionPayload.model_validate(command.payload) + if mission.active_revision is None: + raise EventApplicationError( + "active_plan_required", "交通中断事件必须引用当前激活方案中的候选" + ) + revision_result = await session.execute( + select(PlanRevisionRecord).where( + PlanRevisionRecord.mission_id == mission.mission_id, + PlanRevisionRecord.revision == mission.active_revision, + ) + ) + revision = revision_result.scalar_one_or_none() + if revision is None: + raise EventApplicationError( + "active_plan_not_found", "当前激活方案不存在" + ) + bundle = revision.plan_payload + preferred_id = bundle.get("preferred_option_id") + preferred = next( + ( + item + for item in bundle.get("options", []) + if item.get("option_id") == preferred_id + ), + None, + ) + segment = next( + ( + item + for item in (preferred or {}).get("segments", []) + if item.get("candidate_id") == payload.candidate_id + ), + None, + ) + if segment is None or segment.get("provider") != payload.provider: + raise EventApplicationError( + "candidate_not_in_active_plan", + "交通中断候选与当前激活首选方案不一致", + ) + protected_ids = { + item.get("segment_id") + for item in ( + mission.execution_checkpoint.protected_segments + if mission.execution_checkpoint is not None + else [] + ) + } + if segment.get("segment_id") in protected_ids: + return EventApplicationStatus.RECORDED_ONLY, [] + changes.append( + _change( + f"candidate_filters.transport.{payload.candidate_id}", + None, + { + "provider": payload.provider, + "status": payload.status, + "estimated_delay_minutes": payload.estimated_delay_minutes, + }, + ) + ) + elif event_type == ReplanEventType.WEATHER_RISK: + payload = WeatherRiskPayload.model_validate(command.payload) + changes.append( + _change( + f"candidate_filters.weather.{command.event_id}", + None, + { + "location": payload.location, + "severity": payload.severity, + "affected_task_ids": payload.affected_task_ids, + }, + ) + ) else: return EventApplicationStatus.RECORDED_ONLY, [] @@ -420,17 +558,7 @@ def _to_mission_read(mission: MissionRecord) -> MissionRead: ) for visit in mission.visits ], - expense_policy=ExpensePolicyRead( - snapshot_id=policy.snapshot_id, - policy_id=policy.policy_id, - policy_version=policy.policy_version, - allowed_rail_classes=policy.allowed_rail_classes, - allowed_flight_classes=policy.allowed_flight_classes, - hotel_nightly_cap_yuan=policy.hotel_nightly_cap_yuan, - meal_daily_cap_yuan=policy.meal_daily_cap_yuan, - local_transport_daily_cap_yuan=policy.local_transport_daily_cap_yuan, - trip_total_cap_yuan=policy.trip_total_cap_yuan, - ), + expense_policy=_to_policy_read(policy), transport_preferences=TransportPreferences.model_validate( mission.transport_preferences ), @@ -440,6 +568,25 @@ def _to_mission_read(mission: MissionRecord) -> MissionRead: ) +def _to_policy_read( + policy: ExpensePolicyVersionRecord | ExpensePolicySnapshotRecord, +) -> ExpensePolicyRead: + return ExpensePolicyRead( + snapshot_id=policy.snapshot_id, + snapshot_sequence=getattr(policy, "sequence", 1), + based_on_snapshot_id=getattr(policy, "based_on_snapshot_id", None), + source_event_id=getattr(policy, "source_event_id", None), + policy_id=policy.policy_id, + policy_version=policy.policy_version, + allowed_rail_classes=policy.allowed_rail_classes, + allowed_flight_classes=policy.allowed_flight_classes, + hotel_nightly_cap_yuan=policy.hotel_nightly_cap_yuan, + meal_daily_cap_yuan=policy.meal_daily_cap_yuan, + local_transport_daily_cap_yuan=policy.local_transport_daily_cap_yuan, + trip_total_cap_yuan=policy.trip_total_cap_yuan, + ) + + def _to_event_read( event: ReplanEventRecord, *, diff --git a/backend/app/services/planning_service.py b/backend/app/services/planning_service.py index be3f619..b5ba937 100644 --- a/backend/app/services/planning_service.py +++ b/backend/app/services/planning_service.py @@ -32,7 +32,7 @@ PlanVerifier, PolicyEngine, ) -from app.providers import create_candidate_provider +from app.providers import EventAwareCandidateProvider, create_candidate_provider from app.services.mission_service import ( MissionNotFoundError, RevisionConflictError, @@ -94,6 +94,7 @@ async def generate_plan_revision( raise RevisionConflictError( command.based_on_revision, mission_record.active_revision ) + input_event: ReplanEventRecord | None = None if command.input_event_id is not None: input_event = await session.get(ReplanEventRecord, command.input_event_id) if input_event is None or input_event.mission_id != mission_id: @@ -109,6 +110,17 @@ async def generate_plan_revision( session, mission_id ) provider = create_candidate_provider(settings) + if ( + input_event is not None + and input_event.application_status == "applied" + and input_event.event_type in EventAwareCandidateProvider.supported_event_types + ): + provider = EventAwareCandidateProvider( + provider, + event_id=input_event.event_id, + event_type=input_event.event_type, + payload=input_event.event_payload, + ) policy_engine = PolicyEngine() planner = BoundedMissionPlanner(provider, policy_engine) verifier = PlanVerifier(policy_engine) @@ -171,6 +183,7 @@ async def generate_plan_revision( mission_id=mission_id, preferred_option_id=options[0].option_id, options=options, + policy_snapshot_id=mission.expense_policy.snapshot_id, provider_snapshot_ids=snapshot_ids, generated_at=generated_at, planner_version=planner.version, diff --git a/backend/scripts/smoke_workflow.py b/backend/scripts/smoke_workflow.py index 61d65a5..e965322 100644 --- a/backend/scripts/smoke_workflow.py +++ b/backend/scripts/smoke_workflow.py @@ -163,6 +163,103 @@ def run(base_url: str) -> dict: comparison_response.raise_for_status() comparison = comparison_response.json() + budget_event_id = f"smoke-budget-{run_id}" + budget_event = post( + client, + f"/v1/missions/{mission_id}/events", + { + "event_id": budget_event_id, + "event_type": "budget_changed", + "based_on_revision": 2, + "payload": {"trip_total_cap_yuan": 1800}, + }, + ) + third = post( + client, + f"/v1/missions/{mission_id}/plans", + { + "request_id": f"smoke-plan-r3-{run_id}", + "based_on_revision": 2, + "input_event_id": budget_event_id, + }, + ) + post( + client, + f"/v1/missions/{mission_id}/revisions/3/activate", + {"expected_active_revision": 2}, + ) + + third_option = preferred_option(third) + disrupted_segment = next( + segment + for segment in third_option["segments"] + if segment["segment_type"] == "intercity_transport" + and segment["metadata"].get("direction") == "return" + ) + disruption_event_id = f"smoke-disruption-{run_id}" + disruption_event = post( + client, + f"/v1/missions/{mission_id}/events", + { + "event_id": disruption_event_id, + "event_type": "transport_disruption", + "based_on_revision": 3, + "payload": { + "provider": disrupted_segment["provider"], + "candidate_id": disrupted_segment["candidate_id"], + "status": "cancelled", + }, + }, + ) + fourth = post( + client, + f"/v1/missions/{mission_id}/plans", + { + "request_id": f"smoke-plan-r4-{run_id}", + "based_on_revision": 3, + "input_event_id": disruption_event_id, + }, + ) + post( + client, + f"/v1/missions/{mission_id}/revisions/4/activate", + {"expected_active_revision": 3}, + ) + + weather_event_id = f"smoke-weather-{run_id}" + weather_event = post( + client, + f"/v1/missions/{mission_id}/events", + { + "event_id": weather_event_id, + "event_type": "weather_risk", + "based_on_revision": 4, + "payload": { + "location": mission["visits"][1]["location"]["city"], + "severity": "high", + "affected_task_ids": [mission["visits"][1]["task_id"]], + "summary": "smoke: high weather risk", + }, + }, + ) + fifth = post( + client, + f"/v1/missions/{mission_id}/plans", + { + "request_id": f"smoke-plan-r5-{run_id}", + "based_on_revision": 4, + "input_event_id": weather_event_id, + }, + ) + post( + client, + f"/v1/missions/{mission_id}/revisions/5/activate", + {"expected_active_revision": 4}, + ) + loaded_mission_response = client.get(f"/v1/missions/{mission_id}") + loaded_mission_response.raise_for_status() + loaded_mission = loaded_mission_response.json() + second_option = preferred_option(second) protected_ids = set(execution["protected_segment_ids"]) first_protected = { @@ -178,8 +275,29 @@ def run(base_url: str) -> dict: "service_version": health.json()["version"], "agent_mode": interpreted["trace"]["mode"], "mission_id": mission_id, - "revisions": [first["revision"], second["revision"]], - "event_application": event["application_status"], + "revisions": [ + first["revision"], + second["revision"], + third["revision"], + fourth["revision"], + fifth["revision"], + ], + "event_applications": { + "task_rescheduled": event["application_status"], + "budget_changed": budget_event["application_status"], + "transport_disruption": disruption_event["application_status"], + "weather_risk": weather_event["application_status"], + }, + "policy_snapshot_sequence": loaded_mission["expense_policy"][ + "snapshot_sequence" + ], + "policy_snapshot_bound_to_r3": third["bundle"]["policy_snapshot_id"] + == loaded_mission["expense_policy"]["snapshot_id"], + "disrupted_candidate_excluded": all( + segment.get("candidate_id") != disrupted_segment["candidate_id"] + for option in fourth["bundle"]["options"] + for segment in option["segments"] + ), "execution_version": completion["version"], "protected_segment_count": len(protected_ids), "protected_prefix_unchanged": all( diff --git a/backend/scripts/validate_amap_provider.py b/backend/scripts/validate_amap_provider.py new file mode 100644 index 0000000..843176e --- /dev/null +++ b/backend/scripts/validate_amap_provider.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +from app.config import settings +from app.domain import LocationInput, MealType, SourceMode +from app.providers.amap import AmapLocalRouteProvider +from app.providers.fixture import FixtureCandidateProvider + + +async def validate(output: Path) -> None: + if not settings.amap_api_key: + raise SystemExit("AMAP_API_KEY is required for live provider validation") + provider = AmapLocalRouteProvider( + api_key=settings.amap_api_key, + fallback=FixtureCandidateProvider(), + base_url=settings.amap_base_url, + timeout_seconds=settings.provider_timeout_seconds, + max_retries=settings.provider_max_retries, + max_concurrency=settings.provider_max_concurrency, + max_live_calls=min(settings.provider_max_live_calls, 12), + ) + try: + origin = LocationInput( + name="西湖区客户现场", + address="杭州市西湖区文三路", + city="杭州", + ) + destination = LocationInput( + name="滨江区工作地点", + address="杭州市滨江区江南大道", + city="杭州", + ) + depart_at = datetime.now(ZoneInfo("Asia/Shanghai")).replace( + hour=9, minute=0, second=0, microsecond=0 + ) + routes = await provider.local_routes( + "manual-validation-origin", + "manual-validation-destination", + origin, + destination, + depart_at, + ["transit", "taxi", "walking"], + ) + meals = await provider.nearby_meals( + "manual-validation-origin", + origin, + MealType.LUNCH, + 120, + ) + route_live = [item for item in routes if item.source_mode == SourceMode.LIVE] + meal_live = [item for item in meals if item.source_mode == SourceMode.LIVE] + if not route_live or not meal_live: + raise SystemExit( + "live Amap validation failed: route or meal results used fallback" + ) + snapshots = provider.provider_snapshots() + report = { + "status": "passed", + "validated_at": datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(), + "provider": provider.provider_name, + "route_live_count": len(route_live), + "route_modes": sorted({item.mode.value for item in route_live}), + "meal_live_count": len(meal_live), + "snapshot_summaries": [ + { + "capability": item.capability, + "source_mode": item.source_mode.value, + "query_fingerprint": item.query_fingerprint, + "fetched_at": item.fetched_at.isoformat(), + "expires_at": item.expires_at.isoformat() + if item.expires_at + else None, + } + for item in snapshots + ], + } + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" + ) + print(json.dumps(report, ensure_ascii=False)) + finally: + await provider.aclose() + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Run a minimal live Amap route and meal validation without persisting keys." + ) + parser.add_argument( + "--output", + type=Path, + default=Path("artifacts/amap-provider-validation.json"), + ) + args = parser.parse_args() + asyncio.run(validate(args.output)) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index dc50b13..29951c9 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -25,6 +25,7 @@ async def test_readiness_checks_database() -> None: "local_route_provider": "fixture", "amap_key_configured": False, "llm_key_configured": False, + "manual_inventory_configured": False, } diff --git a/backend/tests/test_missions_api.py b/backend/tests/test_missions_api.py index 4f96d07..2cfc8f3 100644 --- a/backend/tests/test_missions_api.py +++ b/backend/tests/test_missions_api.py @@ -2,7 +2,10 @@ import httpx import pytest +from sqlalchemy import select +from app.db import SessionFactory +from app.db.models import ExpensePolicySnapshotRecord, ExpensePolicyVersionRecord from app.main import app @@ -160,7 +163,7 @@ async def test_replan_event_applies_fact_change_and_rejects_request_id_reuse( @pytest.mark.asyncio -async def test_external_signal_is_recorded_without_claiming_application( +async def test_weather_signal_is_applied_as_candidate_filter( mission_payload: dict, ) -> None: transport = httpx.ASGITransport(app=app) @@ -177,14 +180,17 @@ async def test_external_signal_is_recorded_without_claiming_application( "location": "杭州", "severity": "high", "affected_task_ids": [mission["visits"][0]["task_id"]], - "summary": "暴雨风险,等待路线能力接入", + "summary": "暴雨风险,过滤受影响任务的非机动车路线", }, }, ) assert response.status_code == 200 - assert response.json()["application_status"] == "recorded_only" - assert response.json()["changed_fields"] == [] + payload = response.json() + assert payload["application_status"] == "applied" + assert payload["changed_fields"][0]["path"].startswith( + "candidate_filters.weather." + ) @pytest.mark.asyncio @@ -258,6 +264,9 @@ async def test_supported_events_update_budget_preferences_and_task_collection( }, ) loaded = await client.get(f"/api/v1/missions/{mission_id}") + policy_history = await client.get( + f"/api/v1/missions/{mission_id}/expense-policy/versions" + ) responses = (budget, preference, added, cancelled) assert all(item.status_code == 200 for item in responses), [ @@ -265,6 +274,14 @@ async def test_supported_events_update_budget_preferences_and_task_collection( ] current = loaded.json() assert current["expense_policy"]["trip_total_cap_yuan"] == 1900 + assert current["expense_policy"]["snapshot_id"] != original["expense_policy"][ + "snapshot_id" + ] + assert current["expense_policy"]["snapshot_sequence"] == 2 + assert current["expense_policy"]["based_on_snapshot_id"] == original[ + "expense_policy" + ]["snapshot_id"] + assert current["expense_policy"]["source_event_id"] == "evt-budget-apply-001" assert current["transport_preferences"]["minimum_transfer_minutes"] == 45 assert any(item["name"] == "新增回访" for item in current["visits"]) assert original["visits"][0]["task_id"] not in { @@ -273,6 +290,35 @@ async def test_supported_events_update_budget_preferences_and_task_collection( assert [item["position"] for item in current["visits"]] == list( range(1, len(current["visits"]) + 1) ) + assert policy_history.status_code == 200 + assert [item["snapshot_sequence"] for item in policy_history.json()] == [1, 2] + assert [item["trip_total_cap_yuan"] for item in policy_history.json()] == [ + 1600, + 1900, + ] + + async with SessionFactory() as session: + versions = list( + ( + await session.execute( + select(ExpensePolicyVersionRecord) + .where(ExpensePolicyVersionRecord.mission_id == mission_id) + .order_by(ExpensePolicyVersionRecord.sequence) + ) + ) + .scalars() + .all() + ) + legacy = ( + await session.execute( + select(ExpensePolicySnapshotRecord).where( + ExpensePolicySnapshotRecord.mission_id == mission_id + ) + ) + ).scalar_one() + + assert [item.trip_total_cap_yuan for item in versions] == [1600, 1900] + assert legacy.trip_total_cap_yuan == 1600 @pytest.mark.asyncio diff --git a/backend/tests/test_planning_api.py b/backend/tests/test_planning_api.py index b4d69ca..b0040a6 100644 --- a/backend/tests/test_planning_api.py +++ b/backend/tests/test_planning_api.py @@ -355,6 +355,48 @@ async def test_amap_mode_without_key_persists_honest_fallback_snapshot( ) +@pytest.mark.asyncio +async def test_authorized_manual_inventory_enters_planning_with_manual_source( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import_path = ( + Path(__file__).resolve().parents[2] / "examples" / "manual-inventory-v1.json" + ) + monkeypatch.setattr(settings, "manual_candidate_file", str(import_path)) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + mission = await create_mission(client) + response = await client.post( + f"/api/v1/missions/{mission['mission_id']}/plans", + json={"request_id": "plan-manual-inventory-001", "based_on_revision": None}, + ) + + assert response.status_code == 201, response.text + revision = response.json() + intercity = [ + segment + for option in revision["bundle"]["options"] + for segment in option["segments"] + if segment["segment_type"] == "intercity_transport" + ] + assert intercity + assert all(item["source_mode"] == "manual" for item in intercity) + assert all(item["candidate_id"].startswith("manual-") for item in intercity) + async with SessionFactory() as session: + snapshots = [ + await session.get(ProviderSnapshotRecord, snapshot_id) + for snapshot_id in revision["bundle"]["provider_snapshot_ids"] + ] + manual_snapshot = next( + item + for item in snapshots + if item is not None + and item.capability == "manual_intercity_and_stay_inventory" + ) + assert manual_snapshot.source_mode == "manual" + assert manual_snapshot.payload["content_sha256"] + + @pytest.mark.asyncio async def test_fixture_routes_are_stable_across_equivalent_mission_ids() -> None: payload = load_mission_payload() @@ -420,3 +462,210 @@ async def test_fixture_routes_are_stable_across_equivalent_mission_ids() -> None item.estimated_cost_yuan for item in second_meals ] assert first_meals[0].anchor_ref != second_meals[0].anchor_ref + + +@pytest.mark.asyncio +async def test_transport_disruption_filters_candidate_and_persists_evidence() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + mission = await create_mission(client) + mission_id = mission["mission_id"] + first = await client.post( + f"/api/v1/missions/{mission_id}/plans", + json={"request_id": "plan-before-disruption-001", "based_on_revision": None}, + ) + assert first.status_code == 201, first.text + first_payload = first.json() + disrupted = next( + segment + for segment in first_payload["bundle"]["options"][0]["segments"] + if segment["segment_type"] == "intercity_transport" + and segment["metadata"].get("direction") == "outbound" + ) + activated = await client.post( + f"/api/v1/missions/{mission_id}/revisions/1/activate", + json={"expected_active_revision": None}, + ) + assert activated.status_code == 200, activated.text + event = await client.post( + f"/api/v1/missions/{mission_id}/events", + json={ + "event_id": "evt-transport-cancel-001", + "event_type": "transport_disruption", + "based_on_revision": 1, + "payload": { + "provider": disrupted["provider"], + "candidate_id": disrupted["candidate_id"], + "status": "cancelled", + }, + }, + ) + assert event.status_code == 200, event.text + second = await client.post( + f"/api/v1/missions/{mission_id}/plans", + json={ + "request_id": "plan-after-disruption-001", + "based_on_revision": 1, + "input_event_id": "evt-transport-cancel-001", + }, + ) + + assert event.json()["application_status"] == "applied" + assert second.status_code == 201, second.text + second_payload = second.json() + assert second_payload["bundle"]["policy_snapshot_id"] == mission[ + "expense_policy" + ]["snapshot_id"] + assert all( + segment.get("candidate_id") != disrupted["candidate_id"] + for option in second_payload["bundle"]["options"] + for segment in option["segments"] + ) + async with SessionFactory() as session: + snapshots = [ + await session.get(ProviderSnapshotRecord, snapshot_id) + for snapshot_id in second_payload["bundle"]["provider_snapshot_ids"] + ] + filter_snapshot = next( + item + for item in snapshots + if item is not None and item.capability == "event_candidate_filter" + ) + assert filter_snapshot.source_mode == "manual" + assert filter_snapshot.payload["removed_candidate_ids"] == [ + disrupted["candidate_id"] + ] + + +@pytest.mark.asyncio +async def test_high_weather_filters_walking_for_affected_task() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + mission = await create_mission(client) + mission_id = mission["mission_id"] + first = await client.post( + f"/api/v1/missions/{mission_id}/plans", + json={"request_id": "plan-before-weather-001", "based_on_revision": None}, + ) + assert first.status_code == 201, first.text + activated = await client.post( + f"/api/v1/missions/{mission_id}/revisions/1/activate", + json={"expected_active_revision": None}, + ) + assert activated.status_code == 200, activated.text + affected_task_id = mission["visits"][1]["task_id"] + event = await client.post( + f"/api/v1/missions/{mission_id}/events", + json={ + "event_id": "evt-weather-filter-001", + "event_type": "weather_risk", + "based_on_revision": 1, + "payload": { + "location": mission["visits"][1]["location"]["city"], + "severity": "high", + "affected_task_ids": [affected_task_id], + "summary": "暴雨,避免步行与骑行", + }, + }, + ) + assert event.status_code == 200, event.text + second = await client.post( + f"/api/v1/missions/{mission_id}/plans", + json={ + "request_id": "plan-after-weather-001", + "based_on_revision": 1, + "input_event_id": "evt-weather-filter-001", + }, + ) + + assert second.status_code == 201, second.text + payload = second.json() + assert all( + not ( + segment["segment_type"] == "local_transport" + and segment["metadata"].get("mode") in {"walking", "bicycling"} + and affected_task_id in {segment.get("from_ref"), segment.get("to_ref")} + ) + for option in payload["bundle"]["options"] + for segment in option["segments"] + ) + async with SessionFactory() as session: + snapshots = [ + await session.get(ProviderSnapshotRecord, snapshot_id) + for snapshot_id in payload["bundle"]["provider_snapshot_ids"] + ] + filter_snapshot = next( + item + for item in snapshots + if item is not None and item.capability == "event_candidate_filter" + ) + assert "walking" in filter_snapshot.payload["filtered_modes"] + + +@pytest.mark.asyncio +async def test_disruption_of_protected_candidate_requires_manual_recovery() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + mission = await create_mission(client) + mission_id = mission["mission_id"] + first = await client.post( + f"/api/v1/missions/{mission_id}/plans", + json={"request_id": "plan-protected-disruption-001", "based_on_revision": None}, + ) + assert first.status_code == 201, first.text + preferred = first.json()["bundle"]["options"][0] + outbound = next( + segment + for segment in preferred["segments"] + if segment["segment_type"] == "intercity_transport" + and segment["metadata"].get("direction") == "outbound" + ) + activated = await client.post( + f"/api/v1/missions/{mission_id}/revisions/1/activate", + json={"expected_active_revision": None}, + ) + assert activated.status_code == 200, activated.text + locked = await client.post( + f"/api/v1/missions/{mission_id}/execution/checkpoints", + json={ + "command_id": "exec-protect-disrupted-001", + "based_on_revision": 1, + "expected_version": 0, + "action": "lock_through", + "through_segment_id": outbound["segment_id"], + }, + ) + assert locked.status_code == 200, locked.text + event = await client.post( + f"/api/v1/missions/{mission_id}/events", + json={ + "event_id": "evt-protected-disruption-001", + "event_type": "transport_disruption", + "based_on_revision": 1, + "payload": { + "provider": outbound["provider"], + "candidate_id": outbound["candidate_id"], + "status": "cancelled", + }, + }, + ) + second = await client.post( + f"/api/v1/missions/{mission_id}/plans", + json={ + "request_id": "plan-protected-disruption-002", + "based_on_revision": 1, + "input_event_id": "evt-protected-disruption-001", + }, + ) + + assert event.status_code == 200, event.text + assert event.json()["application_status"] == "recorded_only" + assert second.status_code == 201, second.text + assert all( + any( + segment["segment_id"] == outbound["segment_id"] + and segment["candidate_id"] == outbound["candidate_id"] + for segment in option["segments"] + ) + for option in second.json()["bundle"]["options"] + ) diff --git a/docs/agent-harness.md b/docs/agent-harness.md index 79f0804..bfac95e 100644 --- a/docs/agent-harness.md +++ b/docs/agent-harness.md @@ -77,7 +77,7 @@ FieldPilot 不把无限增长的聊天记录当作业务上下文,也不需要 语义 Agent 的工具数固定为 0。地点路线、餐饮、铁路、航班和酒店候选只在用户确认后,由应用服务调用 `CandidateProvider` 类型化端口。 - 高德路线/餐饮适配器具备超时、重试、错误分类、缓存和来源快照; -- 铁路、航班、酒店当前使用明确标记的 Fixture,不抓取 12306 内部接口,也不冒充实时库存; +- 铁路、航班、酒店可使用明确标记的 Fixture 或严格 Schema 的授权人工导入;人工数据强制标记 `manual` 并保存内容指纹,不抓取 12306 内部接口,也不冒充实时库存; - 公开工作台使用 Mock LLM 和 Fixture Provider,真实 Kimi Eval 在独立工作流运行; - 当前 Provider 只被 FieldPilot 后端消费,因此 Python 端口比 MCP 多一层进程与权限治理更直接。只有同一只读能力确实需要被多个 Agent 客户端复用时,才值得增加 MCP server;预订、支付和报销仍需要独立授权与人工确认。 @@ -125,6 +125,6 @@ FieldPilot 不把无限增长的聊天记录当作业务上下文,也不需要 ## 9. 当前诚实边界 -已验证的是:51 项后端回归、Neon 迁移、Render/Netlify 公网链路、R1/R2 smoke、重启后持久化、精确 CORS,以及上述 Kimi Live Eval。 +本地 `0.6.0` 已验证的是:55 项后端回归、Alembic `20260810_0005` 往返、不可变政策版本、授权人工库存导入、交通/天气候选过滤、工作台与专题页双构建;上一版还验证了 Neon、Render/Netlify 公网链路、R1/R2 smoke、重启后持久化、精确 CORS,以及上述 Kimi Live Eval。 -尚未验证的是:真实高德 Key 的生产运行、铁路/航班/酒店实时库存、生产限流、多租户身份隔离和 SLA。FieldPilot 展示的是可运行、可解释、可重规划的工程闭环,不声称已经具备真实下单能力或全局最优求解。 +尚未验证的是:真实高德 Key 的生产运行、自动同步的铁路/航班/酒店实时库存、生产限流、多租户身份隔离和 SLA;`0.6.0` 的公网迁移与部署也需在发布审批后复验。FieldPilot 展示的是可运行、可解释、可重规划的工程闭环,不声称已经具备真实下单能力或全局最优求解。 diff --git a/docs/architecture.md b/docs/architecture.md index 14c788d..3481c33 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,16 +63,17 @@ Policy Engine 先过滤席别、舱位和单项上限,并对整单成本给出 - Activation:调用方提交 `expected_active_revision`,过期写入返回 409。 - Event:事件 ID、类型、基线和 payload 必须完全一致才算重放。 - Execution:`command_id` 负责命令幂等,`expected_version` 负责乐观并发;锁定和完成边界只能沿首选时间线单调前进。 -- Applied events:任务改期/取消/新增/延长、预算和偏好在同一事务内修改事实并保存 before/after 字段。 -- Recorded-only events:天气与交通中断会留痕,但在候选过滤能力接入前不会标记为已应用。 +- Applied fact events:任务改期/取消/新增/延长和偏好在同一事务内修改事实并保存 before/after 字段。 +- Immutable policy events:预算变化追加新的政策快照;旧版本不覆盖,PlanRevision 绑定实际使用的快照 ID。 +- Candidate-filter events:交通取消/不可用排除指定候选,延误平移候选时间并降低可靠性;天气风险按任务过滤步行/骑行,规则结果进入独立 ProviderSnapshot。 - Revision diff:比较首选方案的稳定任务/候选身份,返回新增、删除、变化、保留段数,以及成本、评分和告警增量。 存在执行检查点时,Planner 以激活修订中的受保护段作为不可变前缀,从锁定段的结束时间和位置恢复搜索状态,只对后续任务、交通、住宿与餐饮进行求解。受保护任务从待规划集合移除,已锁定酒店与步行/换乘/成本状态继续传入后缀搜索;Verifier 会逐段比较前缀内容、确认恢复段存在,并拒绝任何越过检查点的后缀。该能力保证“前缀不变、后缀可重算”,但有界 Beam Search 仍是可解释启发式规划,不声称全局最优。 ## 7. 数据与可观测性 -SQLAlchemy/Alembic 管理 Mission、VisitTask、ExpensePolicy、PlanRevision、ProviderSnapshot、ReplanEvent、AgentRun、ExecutionCheckpoint 和 ExecutionCommand。ExecutionCheckpoint 保存来源修订、锁定/完成边界及受保护段;ExecutionCommand 保存命令指纹与结果,用于安全重放。AgentRun 不保存自由文本原文,只保存 SHA-256 指纹、结构化输出、模型/Prompt 版本、用量、延迟和失败类别。路线与餐饮快照保存查询指纹、归一化候选、来源和安全的 HTTP 事件摘要,不保存 API Key、带 Key 的完整 URL 或原始 Provider 响应。 +SQLAlchemy/Alembic 管理 Mission、VisitTask、ExpensePolicyVersion、PlanRevision、ProviderSnapshot、ReplanEvent、AgentRun、ExecutionCheckpoint 和 ExecutionCommand。政策版本以 sequence、parent snapshot 和 source event 形成不可变链;ExecutionCheckpoint 保存来源修订、锁定/完成边界及受保护段;ExecutionCommand 保存命令指纹与结果,用于安全重放。AgentRun 不保存自由文本原文,只保存 SHA-256 指纹、结构化输出、模型/Prompt 版本、用量、延迟和失败类别。路线、餐饮、人工库存和事件过滤快照保存查询指纹、归一化来源及安全摘要,不保存 API Key、带 Key 的完整 URL 或原始 Provider 响应。 ## 8. 交付边界 -本地已验证 SQLite、51 项 Pytest、Alembic `20260731_0004` 往返、运行中 HTTP 冒烟、真实浏览器执行检查点/后缀重规划链路与 Vue 生产构建。Neon `fieldpilot` 数据库已迁移到 head;Kimi K2.6 已完成 15 场景真实模型评测。Netlify 根路径承载项目专题,`/workbench` 调用 Render Docker/FastAPI;公网 health/ready、R1/R2 smoke、精确 CORS、Neon 持久化和 Render 重启恢复均已验证。公开环境仍使用 Mock LLM 与 Fixture Provider,不具备真实预订、生产限流、多租户隔离或 SLA。 +本地 `0.6.0` 已验证 SQLite、55 项 Pytest、Alembic `20260810_0005` 往返、不可变政策版本、授权人工库存、风险候选过滤,以及工作台/专题页两种 Vue 生产构建。上一版 Neon、Render Docker/FastAPI 与 Netlify 已完成 health/ready、R1/R2 smoke、精确 CORS、持久化和重启恢复验证;Kimi K2.6 已完成 15 场景真实模型评测。公开环境继续使用 Mock LLM 与 Fixture Provider;`0.6.0` 公网升级需在发布审批后复验,不具备真实预订、生产限流、多租户隔离或 SLA。 diff --git a/docs/demo-guide.md b/docs/demo-guide.md index 1d2ea0b..93d7dfa 100644 --- a/docs/demo-guide.md +++ b/docs/demo-guide.md @@ -1,6 +1,6 @@ # FieldPilot 五分钟演示与面试讲解 -快速了解项目可先打开[线上静态专题](https://fieldpilot-kxh.netlify.app/)。专题页用于讲解业务问题、架构、检查点后缀重规划与验证边界;下面的完整任务录入、持久化和重规划演示仍需启动本地前后端。 +快速了解项目可先打开[线上静态专题](https://fieldpilot-kxh.netlify.app/)。专题页用于讲解业务问题、架构、检查点后缀重规划与验证边界;`0.6.0` 合并部署前,新增的政策版本、人工库存和风险过滤应使用本地工作台演示。 ## 演示前准备 @@ -56,12 +56,13 @@ cd D:\CODEX\agent-portfolio\fieldpilot\backend ### 4. 展示执行检查点与后缀重规划(90 秒) -在首个现场任务点击“锁定至此”,再把第二个任务改为 10:00–12:00,点击“应用事件并重规划”。最后回到首个现场任务点击“完成至此”。指出: +在首个现场任务点击“锁定至此”,再选择以下任一事件并点击“应用事件并重规划”:任务改期、总预算调整、未锁定交通取消/延误、受影响任务天气风险。最后回到首个现场任务点击“完成至此”。指出: - 检查点从 V0 → V1 → V2 单调推进,重复 command ID 可安全重放,过期版本会返回冲突; - 已锁定任务不能再选择修改,R2 逐段保留检查点前的交通、缓冲、餐饮和任务内容; - Planner 从锁定段结束时间与位置恢复,只求解后缀,Verifier 独立拒绝前缀篡改或越界后缀; - 事件先在事务内修改 Mission facts,并保存 before/after; +- 预算变化追加不可变政策快照并由 R2 绑定;交通与天气事件通过事件感知 Provider 过滤候选并保存过滤快照; - R2 显式引用 `input_event_id`,激活时使用 expected revision 防止覆盖新版本; - Diff 展示新增、删除、变化和保留路段,以及成本、评分和告警增量。 @@ -69,11 +70,11 @@ cd D:\CODEX\agent-portfolio\fieldpilot\backend ### 5. 主动说明边界(45 秒) -- 高德适配完成契约与故障测试,但尚未使用真实 Key 验收; -- 铁路、航班和酒店当前是冻结 Fixture/Provider Port,未抓取 12306; -- 天气和交通中断目前只 `recorded_only`,尚未进入候选过滤; +- 高德适配完成契约与故障测试,并提供脱敏真实验收工作流,但尚未使用个人 Key 完成生产复验; +- 铁路、航班和酒店支持冻结 Fixture 或严格授权人工导入,未抓取 12306,也不宣称自动同步实时库存; +- 天气和交通中断已进入确定性候选过滤,但事件仍由用户或上游明确提交,尚未订阅实时天气或运营中断流; - 已实现受保护前缀不变的后缀重规划,但 Planner 仍是有界启发式搜索,不声称全局最优; -- Docker/PostgreSQL 配置已提供,本机尚无 Docker CLI 运行证据。 +- Docker/PostgreSQL 配置已提供;Render 云端 Docker 已验证,本机仍无 Docker CLI 运行证据。 ## 面试官追问时打开的材料 diff --git a/docs/deployment-free-tier.md b/docs/deployment-free-tier.md index e72026e..1ae9502 100644 --- a/docs/deployment-free-tier.md +++ b/docs/deployment-free-tier.md @@ -38,11 +38,11 @@ Invoke-RestMethod "$apiBase/api/health" Invoke-RestMethod "$apiBase/api/ready" ``` -随后用 `backend/scripts/smoke_workflow.py --base-url `(脚本支持该参数后)跑完整写入链路,并在 Render 手动重启后读取既有 Mission/Revision/Event,证明数据不依赖临时文件系统。最后发送 `Origin: https://fieldpilot-kxh.netlify.app` 请求,确认仅该生产 origin 获得 CORS 响应。 +随后用 `backend/scripts/smoke_workflow.py --base-url ` 跑完整写入链路,并在 Render 手动重启后读取既有 Mission/Revision/Event,证明数据不依赖临时文件系统。最后发送 `Origin: https://fieldpilot-kxh.netlify.app` 请求,确认仅该生产 origin 获得 CORS 响应。 ## 当前状态 -截至 2026-08-02,三层免费拓扑已上线:Netlify 项目站与工作台为 和 `/workbench`,Render API 为 ,数据进入 Neon `fieldpilot` PostgreSQL。初始生产容器部署 commit 为 `bc53426`,Netlify 生产 deploy 为 `6a6f13259646109fe6f02be6`。 +截至 2026-08-10,三层免费拓扑的上一版已上线:Netlify 项目站与工作台为 和 `/workbench`,Render API 为 ,数据进入 Neon `fieldpilot` PostgreSQL。初始生产容器部署 commit 为 `bc53426`,当前已记录的 Netlify 生产 deploy 为 `6a6f2177f207a9dccf2184db`。`0.6.0` 仍是发布候选,需合并后迁移并复验。 已取得的上线证据: @@ -52,4 +52,4 @@ Invoke-RestMethod "$apiBase/api/ready" - 生产 Netlify origin 获得精确 CORS 许可;随机 Deploy Preview origin 被拒绝。工作台真实浏览器显示 `API ok` 并完成杭州示例 Agent 解析。 - 数据库 owner 凭证在发布后完成轮换,旧凭证失效;仓库、文档和命令输出均不记录连接串。 -免费层边界仍然存在:Render 空闲时可能冷启动;公开环境没有多租户认证和生产限流;高德真实 Key 未验收,铁路、航班与酒店仍是显式 Fixture。因此这是可交互工程演示环境,不承担真实预订或生产 SLA。 +免费层边界仍然存在:Render 空闲时可能冷启动;公开环境没有多租户认证和生产限流;高德真实 Key 未验收。`0.6.0` 已支持铁路、航班与酒店的授权人工候选导入,但公开环境仍使用显式 Fixture。因此这是可交互工程演示环境,不承担真实预订或生产 SLA。 diff --git a/docs/development-log.md b/docs/development-log.md index 7cbe9e5..a726281 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -357,3 +357,16 @@ Stage 4B:增加 AgentRun/DecisionTrace 持久化与请求幂等,建立版本 - 新增 `docs/agent-harness.md`,用可追溯的代码映射说明 Harness 组成、上下文生命周期、Provider 治理、完整业务运行过程、失败降级、审计链和当前未验证边界。 - 公网项目专题增加双层技术架构、四层 Harness 组成和三轮真实 Kimi Eval 对照;同时明确公开工作台使用 Mock LLM/Fixture Provider,真实模型指标来自独立 no-fallback 工作流。 - 将上述已验证事实正式移交“简历制定”和“作品集网站”任务;简历与总作品集只引用最终真实轮次,不把单次调用写成稳定率,也不声称多 Agent、MCP、RAG 或真实预订。 + +## 2026-08-10|Stage 16:0.6.0 核心业务收口 + +- 从 `main@134d436` 建立 `release/fieldpilot-closure-20260810`,保留工作区已有的前端文案、可读性、运行模式和数据边界改进;未删除或覆盖用户改动。 +- 新增 Alembic `20260810_0005` 与 `ExpensePolicyVersionRecord`。初始政策和预算事件形成 sequence、parent snapshot、source event 的追加式版本链;旧投影不再被预算事件覆盖,PlanBundle 记录 `policy_snapshot_id`。 +- 新增 `EventAwareCandidateProvider`:交通取消/不可用排除指定候选,延误平移起止时间并降低可靠性;高风险天气过滤受影响任务的步行和骑行,中风险过滤骑行;过滤命中和规则写入 `event_candidate_filter` 快照。 +- 新增严格的授权人工库存导入:铁路、航班和酒店候选从本地 JSON Schema 加载,来源强制覆盖为 `manual`,保存导入 ID、数量与 SHA-256 内容指纹;不调用或抓取未授权票务接口。 +- 工作台增加任务改期、预算变化、交通中断和天气风险四类事件输入,展示当前不可变政策快照版本;专题页和运行边界文案提高可读性并明确公开数据模式。 +- 新增高德最小真实路线/餐饮验收脚本与手动 Actions 工作流。凭证只来自 secret,artifact 只保留能力、模式、指纹、时间和计数,不保存 Key、完整 URL 或原始 Provider 响应。 +- 本地后端回归增至 `55 passed`;Alembic upgrade/check/downgrade/re-upgrade 通过,最终 head 为 `20260810_0005`;工作台与 showcase 两种 Vue TypeScript/Vite 生产构建通过。 +- 公网只读复核确认 Netlify 首页/工作台均为 200,Render health/ready 可用;线上仍为 `0.5.0-dev`、Mock LLM、Fixture Provider,未把本地 `0.6.0` 能力误记为已发布。 +- 本地启动真实 Uvicorn HTTP 进程执行扩展 smoke,依次生成 R1~R5,覆盖任务改期、预算快照、交通取消和天气风险;四类事件均为 `applied`,政策快照序列为 2,R3 绑定当前快照,取消候选未进入 R4,且检查点受保护前缀逐段不变。 +- 当前仍是本地发布候选。Neon `0005`、Render `0.6.0`、Netlify 新产物、生产 smoke 和真实高德 Key 验收均保留到人工审批后执行,未把本地证据写成公网事实。 diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 97522f9..98cd609 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -1,57 +1,59 @@ -# FieldPilot 0.5.0-dev 发布验收清单 - -验收日期:2026-08-02 - -验收分支:`agent/public-workbench` - -已知稳定回滚点:`bc53426`(首个已验证 Render 生产部署) - -## 已验证 - -- [x] 无 `.env`、Key、依赖目录或构建目录进入 Git。 -- [x] SQLite + Alembic `20260731_0004` 升降级和 schema check。 -- [x] 51 项 Pytest,包含执行检查点单调推进/幂等/并发冲突、严格前缀保留、Verifier 篡改拦截、Neon URL 归一化、Kimi K2.6 非思考结构化输出、确定性澄清/安全标签与显式日期归一化,以及自然语言到事件式 R2 的 API E2E。 -- [x] 删除旧 `/api/field-task/plan`、旧多 Agent、旧模型/服务和未使用前端组件;回归测试固定旧接口返回 404。 -- [x] 15 场景独立 live 数据集、三次重复、延迟/Token/稳定率指标及 OpenAI-compatible 手动工作流;fallback 不进入 live 得分。 -- [x] 时间线生成工作点/酒店附近餐次,按自然日核算餐补;候选、失败原因和来源写入 ProviderSnapshot。 -- [x] 锁定/完成操作更新任务执行态;重规划从检查点恢复,首选 R2 对受保护段保持逐字段一致。 -- [x] Vue TypeScript 检查与 Vite production build。 -- [x] 实际进程 `/api/health`、`/api/ready` 返回成功。 -- [x] Vite `/api` 代理到正式后端端口 8000。 -- [x] `Origin: http://localhost:5173` 获得正确 CORS 响应。 -- [x] 真实浏览器完成 interpret → R1 → lock V1 → event → R2 → complete V2 → diff。 -- [x] 浏览器控制台无 warning/error。 -- [x] Fixture、manual、mock 状态在页面明确显示。 -- [x] 根路径使用 `showcase` 项目专题,`/workbench` 连接实际公网后端;Mock/Fixture 来源在 UI 明确标记。 -- [x] 专题页桌面/移动端无横向溢出,R1/R2 时间线切换有效,浏览器控制台无 warning/error。 -- [x] Neon `fieldpilot` 数据库执行 Alembic 至 `20260731_0004 (head)`,并通过独立就绪查询。 -- [x] Kimi K2.6 完成 15 场景真实评测:15/15 live、无 fallback;最终全量 run 的状态/安全标签准确率 100%,字段 94.87%,澄清 93.33%。 - -## 已配置但未验证 - -- [x] Docker 镜像已由 Render 云端构建并运行;本地 PostgreSQL Compose/Nginx 仍因本机没有 Docker CLI 未实跑。 -- [x] Render Free Blueprint、Neon PostgreSQL URL 归一化、migration-on-start、`/api/ready` 和生产 CORS 配置。 -- [x] 独立 FastAPI/PostgreSQL 公网后端:Render Docker/FastAPI 已连接 Neon,并通过完整 smoke、CORS 和重启恢复验证。 - -## 远端验证 - -- [x] GitHub Actions `verify`:后端测试、Alembic schema check 与前端构建通过。 -- [x] Netlify 项目站与在线工作台:、。 -- [x] 生产 deploy `6a6f13259646109fe6f02be6`:根路径、工作台、SPA 回退、JS/CSS CDN、CSP 与真实浏览器 Agent 解析通过。 -- [x] Render API:;初始生产 commit `bc53426`。 -- [x] GitHub Actions live eval run `30687086569`:15/15 Kimi K2.6 调用为 live,artifact 已下载核验。 - -## 需要外部凭证 - -- [ ] 使用个人高德 Web Service Key 做最小真实地理编码、路线和周边餐饮 POI 验收。 -- [x] 配置 `FIELD_PILOT_LLM_API_KEY` 与供应商 variables,运行 `fieldpilot-live-agent-eval`,下载与 Mock 分开的真实模型报告并记录 run URL/commit。 -- [ ] 如果需要交互地图,单独配置公开的 Web JS Key 与安全码。 - -## 公网后端发布验收 - -- [x] 后端 HTTPS `/api/health` 和 `/api/ready` 返回 200。 -- [x] CORS 只允许实际前端 origin,随机预览 origin 被拒绝。 -- [x] 前端 `VITE_API_BASE_URL` 指向正确 HTTPS 后端并重新构建。 -- [x] 云端 migration 完成;Render 重启后 revision、event 和执行检查点仍存在。 -- [x] health、完整 smoke 与公网真实浏览器链路通过;没有消耗真实 Provider 配额。 -- [x] README 记录已验证事实、初始部署 commit 与 Netlify deploy。 +# FieldPilot 0.6.0 发布验收清单 + +验收日期:2026-08-10 + +发布候选分支:`release/fieldpilot-closure-20260810` + +发布前稳定回滚点:`134d436`(当前 `main`,上一版公网项目站与后端已验证) + +## 本地已验证 + +- [x] 工作区基线变更已保留在独立发布分支,没有删除用户已有的前端可读性与运行边界改进。 +- [x] 无 `.env`、Key、依赖目录、构建目录或评测 artifact 进入 Git。 +- [x] 后端 55 项 Pytest 全部通过。 +- [x] Alembic `20260810_0005` 完成 upgrade、schema check、downgrade 到 `20260731_0004` 与重新 upgrade。 +- [x] 不可变报销政策版本链:预算事件追加新快照,旧投影不覆盖,计划保存 `policy_snapshot_id`。 +- [x] 交通中断候选过滤:取消/不可用排除候选,延误平移时间并降低可靠性,过滤证据进入 ProviderSnapshot。 +- [x] 天气风险候选过滤:高风险过滤受影响任务的步行和骑行,中风险过滤骑行,过滤证据可审计。 +- [x] 授权人工库存导入:铁路、航班和酒店使用严格 JSON Schema,强制 `manual` 来源并保存 SHA-256 内容指纹。 +- [x] Vue 工作台支持任务改期、预算变化、交通中断和天气风险四类可演示事件,并展示政策快照版本。 +- [x] Vue TypeScript 检查、工作台 Vite production build 与 `showcase` production build 均通过。 +- [x] CI 同时构建工作台与实际 Netlify 使用的 showcase 模式。 +- [x] 高德真实服务验收脚本与手动 GitHub Actions 工作流已提供;报告不保存 Key、带 Key URL 或原始响应。 +- [x] 本地真实 HTTP 进程完成 R1→R5 smoke:任务改期、预算快照、交通取消和天气风险均为 `applied`,政策快照绑定正确、受影响候选被排除、检查点前缀保持不变。 + +## 既有真实模型证据 + +- [x] Kimi K2.6 固定 15 场景最终轮为 15/15 Live、无 fallback。 +- [x] 最终轮状态与安全标签准确率 100%,选定字段精确率 94.87%,澄清字段精确率 93.33%,P50/P95 为 16.91/26.92 秒。 +- [x] 三轮数据来自三个代码版本,每个版本每场景调用一次;不把它表述为同一版本三次重复,也不声明跨重复稳定率。 +- [x] Live Eval 与 Mock 数据集、指标和 artifact 分离;任何 fallback 都会让 Live 工作流失败。 + +## 上一版公网已验证 + +- [x] Netlify 项目站与工作台:、。 +- [x] 2026-08-10 只读复核:Netlify 两个入口均返回 200;Render health/ready 可用并明确报告当前线上仍为 `0.5.0-dev`、Mock LLM 与 Fixture Provider。 +- [x] 当前生产 deploy `6a6f2177f207a9dccf2184db` 已验证根路径、工作台、SPA 回退、JS/CSS CDN、CSP 与浏览器主链路。 +- [x] Render API:;health/ready、精确 CORS、完整 R1/R2 smoke 和重启恢复均通过。 +- [x] Neon PostgreSQL 上一版已迁移到 `20260731_0004`,数据在 Render 重启后仍可读取。 + +## 需要人工审批或外部凭证 + +- [ ] 审查并合并 `release/fieldpilot-closure-20260810` 到 `main`,触发 GitHub Actions、Render 和 Netlify 生产发布。 +- [ ] 确认 Neon 生产迁移到 `20260810_0005`,再验证 `/api/ready`、完整 R1/R2、四类事件和重启恢复。 +- [ ] 确认 Netlify 生产页显示 `0.6.0`、55 项测试口径、运行边界和新事件工作台。 +- [ ] 配置 GitHub secret `FIELD_PILOT_AMAP_API_KEY`,手动运行 `fieldpilot-amap-provider-validation` 并保存脱敏报告。 +- [ ] 只有需要浏览器交互地图时才配置独立 Web JS Key 与安全码;后端 Web Service Key 不得进入前端。 + +## 发布后验收命令 + +```powershell +Invoke-RestMethod https://fieldpilot-api-t7m6.onrender.com/api/health +Invoke-RestMethod https://fieldpilot-api-t7m6.onrender.com/api/ready + +cd D:\CODEX\agent-portfolio\fieldpilot\backend +.\.venv\Scripts\python.exe scripts\smoke_workflow.py ` + --base-url https://fieldpilot-api-t7m6.onrender.com/api +``` + +生产发布未完成前,只能说“0.6.0 发布候选已本地验收”,不能把新迁移和新事件过滤写成已在公网验证。 diff --git a/docs/resume-project-description.md b/docs/resume-project-description.md index 7667f5c..edf7f97 100644 --- a/docs/resume-project-description.md +++ b/docs/resume-project-description.md @@ -10,7 +10,7 @@ PydanticAI、FastAPI、Pydantic v2、SQLAlchemy/Alembic、Vue 3/TypeScript、htt - 针对本人跨省市外勤中交通、住宿、多工作点时窗与报销规则分散的问题,设计“类型化语义 Agent Harness + 确定性编排内核”:用严格契约、有界模型调用、确定性后置校验、幂等与运行审计管理自然语言入口,避免 LLM 编造车次、价格和合规结论。 - 实现异步 Candidate Provider 与有界 Beam Search,融合跨城交通、住宿和市内路线候选,Policy Engine 过滤席别/舱位及费用上限,Verifier 独立复算任务覆盖、时间冲突、分类成本和政策不变量,输出最多 3 个带评分明细的可解释方案。 - 封装高德地理编码与 v5 路径规划适配器,加入显式超时、有限重试/并发/调用预算、查询缓存、in-flight 去重和按路线方式降级;通过 ProviderSnapshot 与 `live/mixed/fixture` 标记保留来源和失败证据。 -- 建立 Mission/PlanRevision/ReplanEvent/ExecutionCheckpoint/AgentRun 持久化,以输入指纹、命令幂等、乐观并发和严格前缀保护实现检查点后的后缀重规划;完成 51 项后端回归,并部署 Render Docker/FastAPI、Neon PostgreSQL 与 Netlify 工作台。 +- 建立 Mission/PlanRevision/ReplanEvent/ExecutionCheckpoint/AgentRun 与不可变政策版本持久化,以输入指纹、命令幂等、乐观并发和严格前缀保护实现检查点后的后缀重规划;本地 `0.6.0` 完成 55 项后端回归,上一版已部署 Render Docker/FastAPI、Neon PostgreSQL 与 Netlify 工作台。 - 建立 15 场景版本化 Kimi K2.6 Live Eval,覆盖完整/缺失输入、精确报销字段、任务时窗和 Prompt Injection;依据失败样本重算澄清与安全标签,最终 15/15 live、状态/安全 100%、字段 94.87%、澄清 93.33%,且 fallback 不计入模型指标。 篇幅受限时,保留 Harness、确定性规划/校验和真实 Eval 三条,把部署与测试数合并到项目链接或面试讲解中。 @@ -34,7 +34,7 @@ Agent Harness 负责从不完整口语中抽取多类约束并生成最少澄清 当前唯一需要模型推理的是语义抽取,工具数固定为 0,避免用户文本直接触发外部网络或副作用。Provider 只由本后端消费,类型化 Python 端口已能更直接地治理超时、预算、缓存、来源和降级;等同一只读能力需要跨多个 Agent 客户端复用时再引入 MCP。 **12306 是否已接入?** -没有。当前铁路/航班/酒店是明确 Fixture/Port;不调用或抓取未授权内部接口。高德适配已做契约和故障测试,真实 Key 尚未验收。 +没有。铁路/航班/酒店使用明确 Fixture,或由用户通过严格 Schema 导入授权候选;不调用或抓取未授权内部接口。高德适配已做契约和故障测试并提供脱敏验收工作流,真实 Key 尚未验收。 ## 暂时不能写 diff --git a/docs/roadmap.md b/docs/roadmap.md index 8093f6d..fca8a3f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,27 +1,29 @@ -# FieldPilot 后续迭代边界 +# FieldPilot 发布后迭代边界 -## P0:交付验收 +## P0:0.6.0 生产验收 -- [云端已完成] Render Docker + Neon PostgreSQL 已验证 migration、ready probe、数据持久化与重启恢复;本地 Compose/Nginx 仍因本机无 Docker CLI 未实跑。 -- 用独立高德 Web Service Key 执行最小真实路线验收,保存脱敏快照;用模型 Key 跑 `mission-interpret-v1` 固定集,生成与 Mock 分开的延迟、Token、失败率和字段指标。 -- [已完成] 增加浏览器主链路验收与工作台截图,发布独立项目站、在线工作台和可写公网后端;不复用智能旅游助手域名。 +- [发布候选已完成] 本地 55 项测试、Alembic `20260810_0005` 往返、工作台与专题页双构建通过。 +- [待人工审批] 合并发布分支,完成 Neon 迁移、Render/Netlify 部署、完整 smoke、浏览器主链路和重启恢复验收。 +- [待外部凭证] 使用独立高德 Web Service Key 运行 `fieldpilot-amap-provider-validation`,保存不含密钥和原始响应的脱敏报告。 +- [可选] 只有产品确实需要交互地图时才配置浏览器 Web JS Key 与安全码。 -## P1:补齐核心业务 +## P1:核心业务闭环 -- [已完成] 增加高德周边餐饮 POI/Fixture 候选、时间线餐次和逐日餐补核算;缺价或失败时保留来源与降级证据。 -- 为铁路/航班/酒店实现授权 Provider 或人工候选导入,不抓取 12306 内部接口。 -- 让 transport disruption/weather risk 真正过滤候选;在此之前保持 `recorded_only`。 -- [已完成] 引入 segment lock 与执行进度,严格保留 completed/locked 前缀,只重新求解受影响后缀;命令重放与并发冲突已有接口测试。 -- 政策改为不可变快照历史,而不是在事件审计保护下更新当前快照行。 +- [已完成] 高德周边餐饮 POI/Fixture 候选、时间线餐次和逐日餐补核算。 +- [已完成] 铁路、航班和酒店的严格人工候选导入;来源固定为 `manual` 并保存内容指纹,不抓取 12306 内部接口。 +- [已完成] 交通取消/不可用/延误与天气风险进入确定性候选过滤,并保存事件过滤 ProviderSnapshot。 +- [已完成] segment lock、执行检查点、严格前缀保护、后缀重规划、命令重放和并发冲突处理。 +- [已完成] 报销政策使用追加式不可变版本链;预算事件生成新快照,计划绑定具体政策快照。 ## P2:达到真实规模后再引入 -- OR-Tools:当任务/候选数量和资源约束使 Beam Search 质量或耗时不再满足固定集时。 -- Pydantic Evals:当需要模型/Prompt/数据集实验矩阵和持续报告,而当前脚本难以维护时。 -- LlamaIndex/RAG:当用户真的上传多份 SOP、报销文档或现场证据,并需要引用定位时。 -- CrewAI Flows:仅当出现多个必要的长时自治角色、可恢复分支与人工节点;当前数据库状态机足够。 -- Redis/队列/SSE:当真实调用超过同步请求预算,需后台作业、取消和进度流时。 +- OR-Tools:当任务、候选或资源约束使 Beam Search 的质量或耗时不再满足固定集时。 +- Pydantic Evals:当需要大规模模型/Prompt/数据集实验矩阵,现有版本化脚本难以维护时。 +- LlamaIndex/RAG:当用户真实上传多份 SOP、报销文件或现场证据,并需要引用定位时。 +- CrewAI Flows:仅当出现多个必要的长时自治角色、可恢复分支和人工节点;当前数据库状态机足够。 +- Redis/队列/SSE:当真实调用超过同步请求预算,需要后台作业、取消和进度流时。 +- 身份与租户:进入真实企业试用前增加OIDC/RBAC、租户隔离、限流、审计保留策略和数据删除流程。 ## 明确不做 -自动支付、购票、订房、叫车、提交报销;绕过平台授权;虚构生产客户、并发量、准确率或节省比例。 +自动支付、购票、订房、叫车、提交报销;绕过平台授权;虚构生产客户、并发量、准确率、节省比例或全局最优结果。 diff --git a/examples/manual-inventory-v1.json b/examples/manual-inventory-v1.json new file mode 100644 index 0000000..f585cfe --- /dev/null +++ b/examples/manual-inventory-v1.json @@ -0,0 +1,70 @@ +{ + "import_id": "manual-hangzhou-20260806-v1", + "source_label": "authorized-manual-demo", + "outbound": [ + { + "candidate_id": "manual-g7315", + "provider": "manual-import", + "source_mode": "manual", + "mode": "rail", + "direction": "outbound", + "from_ref": "mission-origin", + "to_ref": "arrival-hub", + "depart_at": "2026-08-06T08:00:00+08:00", + "arrive_at": "2026-08-06T12:00:00+08:00", + "price_yuan": 310, + "cabin_class": "second_class", + "transfers": 0, + "reliability_score": 92, + "metadata": {"service_name": "人工录入高铁候选"} + }, + { + "candidate_id": "manual-flight-out-01", + "provider": "manual-import", + "source_mode": "manual", + "mode": "flight", + "direction": "outbound", + "from_ref": "mission-origin", + "to_ref": "arrival-hub", + "depart_at": "2026-08-06T08:50:00+08:00", + "arrive_at": "2026-08-06T10:20:00+08:00", + "price_yuan": 620, + "cabin_class": "economy", + "transfers": 0, + "reliability_score": 82, + "metadata": {"service_name": "人工录入航班候选"} + } + ], + "returns": [ + { + "candidate_id": "manual-g7520", + "provider": "manual-import", + "source_mode": "manual", + "mode": "rail", + "direction": "return", + "from_ref": "departure-hub", + "to_ref": "mission-origin", + "depart_at": "2026-08-07T17:30:00+08:00", + "arrive_at": "2026-08-07T21:30:00+08:00", + "price_yuan": 310, + "cabin_class": "second_class", + "transfers": 0, + "reliability_score": 90, + "metadata": {"service_name": "人工录入返程高铁"} + } + ], + "stays": [ + { + "candidate_id": "manual-hotel-work-01", + "provider": "manual-import", + "source_mode": "manual", + "name": "人工录入任务点酒店", + "address": "杭州市西湖区文三路附近", + "city": "杭州", + "nightly_price_yuan": 420, + "rating": 4.6, + "metadata": {"authorization": "user-supplied"} + } + ], + "notes": "示例文件只演示严格导入契约,不代表实时库存、价格或可预订状态。" +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6b0a926..bfa57dc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "fieldpilot-web", - "version": "0.5.0-dev", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fieldpilot-web", - "version": "0.5.0-dev", + "version": "0.6.0", "dependencies": { "@vitejs/plugin-vue": "^6.0.0", "vue": "^3.5.0" diff --git a/frontend/package.json b/frontend/package.json index 566a4a0..55bf4ad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "fieldpilot-web", - "version": "0.5.0-dev", + "version": "0.6.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0821170..188970e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,5 @@ @@ -268,4 +267,123 @@ const stack = [ .harness-section{position:relative}.harness-heading{align-items:start}.harness-boundary{display:grid;gap:18px;padding:28px;background:#fff;border:1px solid #d4ded7;border-radius:22px;box-shadow:0 20px 60px rgba(23,63,48,.07)}.harness-lane{display:grid;grid-template-columns:minmax(130px,.8fr) repeat(7,auto);gap:10px;align-items:center}.harness-lane.business-lane{grid-template-columns:minmax(130px,.8fr) repeat(9,auto)}.lane-label{align-self:stretch;display:flex;align-items:center;padding:12px;color:#1c694a;background:#e7f0ea;border-radius:11px;font-size:9px;font-weight:800;letter-spacing:.11em}.harness-node{min-width:122px;padding:16px 13px;background:#f3f5f2;border:1px solid #d9e0da;border-radius:12px;font-size:11px;font-weight:800;line-height:1.5}.harness-node small{color:#718078;font-size:8px;font-weight:600}.harness-node.accent{color:#fff;background:#174e39;border-color:#174e39}.harness-node.accent small{color:#b9d4c5}.harness-lane i{color:#8aa096;font-style:normal}.boundary-gate{display:flex;align-items:center;justify-content:center;gap:14px;color:#d45d39;font-size:9px;font-weight:800;letter-spacing:.12em}.boundary-gate b{font-size:17px}.harness-layer-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:18px}.harness-layer-grid article{padding:22px;background:#173f30;border-radius:15px;color:#edf6ef}.harness-layer-grid span{color:#c8ee55;font-size:9px;font-weight:800}.harness-layer-grid strong{display:block;margin-top:10px;font-size:14px}.harness-layer-grid p{margin:8px 0 0;color:#adc4b7;font-size:10px;line-height:1.7}.eval-story{display:grid;grid-template-columns:.85fr 1.15fr;gap:28px;margin-top:18px;padding:28px;background:#eef2ed;border-radius:20px}.eval-copy h3{margin:0;font-size:25px;line-height:1.22}.eval-copy>p:not(.kicker){color:#647169;font-size:11px;line-height:1.75}.eval-copy small{color:#1c694a;font-size:9px;font-weight:800}.eval-table{display:grid;align-content:center;min-width:0;overflow-x:auto}.eval-row{display:grid;grid-template-columns:1.7fr repeat(4,.65fr);min-width:530px;border-bottom:1px solid #d2dcd4}.eval-row>*{padding:11px 8px;font-size:10px;text-align:right}.eval-row>*:first-child{text-align:left}.eval-row b{color:#1c694a}.eval-head{color:#78847c;font-size:8px;font-weight:800;letter-spacing:.06em}.eval-head>*{font-size:8px}.eval-row:last-child{border-bottom:0} @media(max-width:900px){.harness-lane,.harness-lane.business-lane{grid-template-columns:1fr}.harness-lane i{justify-self:center;transform:rotate(90deg)}.harness-layer-grid{grid-template-columns:repeat(2,1fr)}.eval-story{grid-template-columns:1fr}} @media(max-width:560px){.harness-boundary,.eval-story{padding:19px}.harness-layer-grid{grid-template-columns:1fr}.harness-node{min-width:0}.eval-copy h3{font-size:21px}} +.showcase-hero { + grid-template-columns: 1.2fr .8fr; + gap: clamp(40px, 6vw, 72px); + min-height: 0; + padding: clamp(56px, 7vw, 88px) 0; +} + +.showcase-hero h1 { + max-width: 15ch; + font-size: clamp(36px, 4.2vw, 52px); + line-height: 1.06; + letter-spacing: -.045em; +} + +.hero-summary { + max-width: 620px; + margin-top: 24px; + font-size: 15px; + line-height: 1.75; +} + +.hero-actions { + margin-top: 28px; +} + +.mission-card { + padding: 28px; + border-radius: 20px; +} + +.mission-card > strong { + font-size: 26px; +} + +.route-line { + margin: 22px 0; +} + +.showcase-section { + padding: clamp(72px, 8vw, 96px) 0; +} + +.section-heading h2 { + font-size: clamp(30px, 4vw, 50px); + line-height: 1.12; + letter-spacing: -.04em; +} + +.boundary-section h2 { + font-size: clamp(30px, 4vw, 48px); + line-height: 1.12; + letter-spacing: -.04em; +} + +/* Supporting labels remain compact, but never fall into unreadable micro type. */ +.showcase-nav > div, +.button, +.eyebrow, +.kicker { font-size: 13px; } +.source-link, +.card-label, +.mission-card dt, +.fact-strip span, +.trace-steps li > span, +.timeline-card header span, +.revision-tabs button, +.showcase-timeline time, +.showcase-timeline p, +.timeline-card > footer, +.architecture-flow span, +.architecture-flow small, +.stack-grid p, +.evidence-grid span, +.evidence-actions a, +.showcase-footer, +.lane-label, +.harness-node, +.boundary-gate, +.harness-layer-grid span, +.harness-layer-grid p, +.eval-copy > p:not(.kicker), +.eval-copy small, +.eval-row > *, +.eval-head, +.eval-head > * { font-size: 12px; } +.showcase-timeline span, +.harness-node small { font-size: 11px; } +.evidence-grid p { font-size: 13px; } + +.showcase-nav { + isolation: isolate; + border-bottom: 0; + background: transparent; + backdrop-filter: none; +} + +.showcase-nav::before { + content: ""; + position: absolute; + inset-block: 0; + inset-inline: calc(50% - 50vw); + z-index: -1; + border-bottom: 1px solid #d9dfd8; + background: rgba(244, 246, 241, .92); + backdrop-filter: blur(12px); +} + +@media(max-width: 560px) { + .showcase-hero { + grid-template-columns: 1fr; + gap: 42px; + padding: 56px 0; + } + + .showcase-hero h1 { + font-size: 34px; + white-space: nowrap; + } +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4350071..60f77d3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -2,6 +2,14 @@ import type { InterpretMissionResponse } from "../types/agent"; import type { Mission, MissionCreate, ReplanEventCreate, ReplanEventReceipt } from "../types/mission"; import type { ExecutionCheckpoint, ExecutionCheckpointCommand, PlanGenerationRequest, PlanRevision, RevisionActivationReceipt, RevisionDiff } from "../types/planning"; +export interface HealthResponse { + status: string; + service: string; + version: string; + local_route_provider: string; + agent_mode: "mock" | "live"; +} + const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || "/api").replace(/\/$/, ""); async function request(path: string, init?: RequestInit): Promise { @@ -21,7 +29,7 @@ async function request(path: string, init?: RequestInit): Promise { return response.json(); } -export const checkHealth = () => request<{ status: string }>("/health"); +export const checkHealth = () => request("/health"); export const interpretMission = (text: string, referenceDate: string) => request("/v1/agent/interpret-mission", { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index bf65230..7a3d492 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -27,9 +27,9 @@ main { width: min(1240px, calc(100% - 40px)); margin: 0 auto; } .hero { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(320px, .55fr); gap: 80px; padding: 88px 0 70px; align-items: end; } .eyebrow, .result-label { margin: 0 0 18px; color: var(--green); font-weight: 700; font-size: 12px; letter-spacing: .18em; } -.hero h1 { margin: 0; max-width: 760px; font-size: clamp(46px, 6.2vw, 84px); line-height: 1.04; letter-spacing: -.055em; font-weight: 600; } +.hero h1 { margin: 0; max-width: 760px; font-size: clamp(38px, 4.6vw, 56px); line-height: 1.06; letter-spacing: -.045em; font-weight: 600; } .hero h1 em { color: var(--green); font-style: normal; } -.hero-description { max-width: 680px; margin: 30px 0 28px; color: var(--muted); font-size: 17px; line-height: 1.9; } +.hero-description { max-width: 620px; margin: 24px 0 26px; color: var(--muted); font-size: 15px; line-height: 1.75; } .capability-row { display: flex; flex-wrap: wrap; gap: 10px; } .capability-row span { padding: 8px 12px; border: 1px solid #c8d1c9; border-radius: 999px; color: #3d5046; background: rgba(255,255,255,.55); font-size: 12px; } .flow-card { padding: 28px; color: white; background: var(--green-dark); border-radius: 22px; box-shadow: 0 24px 60px rgba(25,67,49,.18); } @@ -142,7 +142,7 @@ button.ghost { color: var(--green); background: transparent; border: 1px solid # .topbar nav a { color: var(--green); text-decoration: none; } .text-button { padding: 7px 10px; color: var(--green); background: transparent; border: 1px solid var(--line); } .compact-hero { grid-template-columns: minmax(0, 1.3fr) minmax(330px, .7fr); padding: 64px 0 45px; align-items: center; } -.compact-hero h1 { font-size: clamp(46px, 5.4vw, 72px); } +.compact-hero h1 { font-size: clamp(38px, 4.2vw, 52px); } .status-card { padding: 26px; color: white; background: var(--green-dark); border-radius: 20px; } .status-card > span { color: #9fc1af; font-size: 10px; letter-spacing: .15em; } .status-card > strong { display: block; margin: 10px 0 22px; font-size: 21px; } @@ -231,6 +231,68 @@ button.full { width: 100%; margin-top: 10px; } .diff-panel b { color: var(--orange); font-size: 10px; text-transform: uppercase; } .diff-panel small { grid-column: 2; color: var(--muted); font-family: monospace; } +/* Keep runtime truth and supporting evidence readable in the public workbench. */ +.runtime-boundary { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + gap: 12px; + align-items: center; + margin: 16px 0 0; + padding: 14px 16px; + border: 1px solid #e2d2a6; + border-radius: 12px; + background: #fff8e7; +} +.runtime-boundary div { min-width: 132px; padding-right: 14px; border-right: 1px solid #e8d8ad; } +.runtime-boundary span, .runtime-boundary strong { display: block; } +.runtime-boundary span { color: #806b35; font-size: 12px; font-weight: 700; } +.runtime-boundary strong { margin-top: 3px; color: #403719; font-size: 14px; } +.runtime-boundary p { margin: 0; color: #695c36; font-size: 13px; line-height: 1.65; } + +.flow-card > span, +.section-heading span, +.metric-grid span, +.map-fallback span, +.risk-grid article > div span, +.day-card header span:first-child, +.target-index, +.status-card > span, +.draft-summary dt, +.visit-preview span, +.option-tabs span, +.timeline article p, +.v1-cost-ledger dt, +.diff-metrics span { font-size: 12px; } + +.tool-list li > span, +[class^="source-"], +.risk-pill, +.execution-pill, +.policy-list li > span, +.diff-panel b { font-size: 11px; } + +.tool-list p, +.page-footer, +.step-line, +.timeline article > div > small, +.execution-actions button, +.policy-list small { font-size: 12px; } + +.segment-facts { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 8px 0 9px; +} +.segment-facts span { + padding: 4px 7px; + border-radius: 6px; + color: #4f6257; + background: #edf1ed; + font-size: 12px; + line-height: 1.4; +} + @media (max-width: 900px) { main { width: min(100% - 24px, 760px); } .hero, .form-card, .result-hero, .two-column { grid-template-columns: 1fr; } @@ -241,11 +303,13 @@ button.full { width: 100%; margin-top: 10px; } .risk-grid { grid-template-columns: 1fr; } .compact-hero, .workspace-grid, .replan-form { grid-template-columns: 1fr; } .option-tabs { grid-template-columns: 1fr; } + .runtime-boundary { grid-template-columns: 1fr 1fr; } + .runtime-boundary p { grid-column: 1 / -1; } } @media (max-width: 560px) { .topbar > div:last-child { display: none; } - .hero h1 { font-size: 43px; } + .hero h1 { font-size: 38px; } .form-grid, .map-fallback ol { grid-template-columns: 1fr; } label.wide { grid-column: auto; } .metric-grid { grid-template-columns: 1fr 1fr; } @@ -253,6 +317,12 @@ button.full { width: 100%; margin-top: 10px; } .target-list > article { grid-template-columns: 30px 1fr; } .target-actions { grid-column: 2; } .page-footer { display: grid; gap: 6px; } + .runtime-boundary { grid-template-columns: 1fr; } + .runtime-boundary div { min-width: 0; padding: 0 0 9px; border-right: 0; border-bottom: 1px solid #e8d8ad; } + .runtime-boundary p { grid-column: auto; } + .topbar nav { gap: 9px; margin-right: 8px; } + .topbar nav a { display: none; } + .text-button { padding-inline: 8px; } } @media print { diff --git a/frontend/src/types/mission.ts b/frontend/src/types/mission.ts index 5afd7b6..07ac0f0 100644 --- a/frontend/src/types/mission.ts +++ b/frontend/src/types/mission.ts @@ -67,6 +67,9 @@ export interface ExpensePolicyInput { export interface ExpensePolicySnapshot extends ExpensePolicyInput { snapshot_id: string; + snapshot_sequence: number; + based_on_snapshot_id?: string | null; + source_event_id?: string | null; } export interface MissionCreate { diff --git a/frontend/src/types/planning.ts b/frontend/src/types/planning.ts index 261e265..7b17002 100644 --- a/frontend/src/types/planning.ts +++ b/frontend/src/types/planning.ts @@ -70,6 +70,7 @@ export interface PlanBundle { mission_id: string; preferred_option_id: string; options: PlanOption[]; + policy_snapshot_id?: string | null; provider_snapshot_ids: string[]; generated_at: string; planner_version: string; diff --git a/render.yaml b/render.yaml index 0500674..f8ed49b 100644 --- a/render.yaml +++ b/render.yaml @@ -12,7 +12,7 @@ services: dockerContext: . envVars: - key: APP_VERSION - value: 0.5.0-dev + value: 0.6.0 - key: DATABASE_URL sync: false - key: DATABASE_AUTO_CREATE