Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/amap-provider-validation.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,4 @@ jobs:
run: |
npm ci
npm run build
npm run build:showcase
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。在对应实现、评测和部署证据完成前,目标设计中的能力不得写成已落地事实。

Expand All @@ -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 锁定/完成已执行前缀
Expand All @@ -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。
Expand All @@ -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、全局路线最优化 | 未实现 | 不属于当前已验收能力 |
Expand Down Expand Up @@ -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
Expand All @@ -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)。真实密钥完成复验前,不应声称线上高德可用。

## 验证

Expand All @@ -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 冒烟(需要先启动后端):

Expand Down
3 changes: 2 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
83 changes: 83 additions & 0 deletions backend/alembic/versions/20260810_0005_policy_history.py
Original file line number Diff line number Diff line change
@@ -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")
1 change: 1 addition & 0 deletions backend/app/api/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
16 changes: 16 additions & 0 deletions backend/app/api/missions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
ActivateRevisionRequest,
ExecutionCheckpointCommand,
ExecutionCheckpointRead,
ExpensePolicyRead,
MissionCreate,
MissionRead,
PlanGenerationRequest,
Expand All @@ -29,6 +30,7 @@
RevisionConflictError,
create_mission,
get_mission,
list_expense_policy_versions,
record_replan_event,
)
from app.services.planning_service import (
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 55 additions & 2 deletions backend/app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions backend/app/domain/mission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions backend/app/domain/planning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading