## 文档移动(docs/ -> docs/演示用/) `代码结构与关键逻辑梳理-2026-09-14.md`、`后端接口文档-2026-09-14.md`、 `软件需求文档-2026-09-14.md` 移入 `docs/演示用/`。 git 识别为 **rename**(状态 `R`),三份文档的历史完整保留,不是"删掉再新建"。 ## .workbuddy/ 加入 .gitignore 它与文件里已有的 `.agents/`、`skills-lock.json` 属同一类 —— AI 编码助手的工具产物, 不是项目内容。放在同一节下,避免以后 `git add -A` 时误提交。 ## 未包含(有意留下) `docs/23-记忆分层与画像设计.md` 的工作区改动**没有**一并提交:那不是我改的, 也不在这次确认的范围内,留在工作区由作者决定何时提交。 (暂存时被 `git add docs` 一并带上,已 `git restore --staged` 退回。)
23 KiB
代码结构与关键逻辑梳理(只读分析报告)
分析日期:2026-09-14 分析范围:
C:\Users\Windows\Desktop\项目代码全仓库(app/+tools/+docs/+tests/) 分析方式:纯阅读,未修改任何代码或文档(本报告为新增文件) 分析依据:AGENTS.md的强制约束、docs/核心 7 份文档、全部 Python 业务代码、app/static/portal/全部 44 个前端模块
〇、结论摘要(先看这一段)
这是一套**「通用 Agent 平台 + 多业务域」**的金融模拟交易系统,架构为 MVC+S,核心是一个被治理骨架严格约束的 Agent 运行时。三条主线:
| 主线 | 入口 | 关键机制 |
|---|---|---|
| 请求链路 | HTTP 受理 202 → Outbox 事件 → Worker 领单 → Agent 执行 → 落结果 | 三段式异步、租约围栏、幂等回执 |
| 记忆链路 | 对话 → 记忆单元 → 事实晋升 → 画像快照 → 向量/图投影 | MySQL 唯一真相、投影单向可重建 |
| 治理链路 | Agent 输出 → 引用校验 → 负面词拦截 → 脱敏 → 免责声明 | 同步、无 DB、失败关闭 |
本次发现的最重要问题(1 个 P0 级):
🔴
app/static/portal/employee-console/workspace/workspace.js存在真实语法错误,整个管理员工作台无法加载。已用真实 ESM 解析器确认(44 个前端模块中唯一失败项)。详见 §六.1。
一、整体结构
1.1 目录分层(MVC+S 的物理映射)
app/
├── main.py # FastAPI 组装入口(模块级变量是 app,不是 application)
├── core/ # 契约与横切关注点:contracts / errors / config / knowledge_schema
├── api/ # ── Controller 层 ──
│ ├── controllers/ # 22 个路由模块(按业务域切分)
│ ├── dependencies/ # auth / database / rate_limit 等注入
│ ├── middleware.py # trace_id 注入
│ └── views/envelope.py # 统一信封(envelope / list_envelope)
├── service/ # ── Service 层(含全部业务 Agent)──
│ ├── agent/ # BaseAgent / AgentFactory / AgentExecutor / governance
│ │ ├── implementations/# 7 个业务 Agent
│ │ └── tools/ # 工具注册表与 handler
│ ├── *_service.py # 43 个业务 Service
│ └── ...
├── repository/ # ── Model 层(数据访问)── 15 个 Repository
├── model/ # ORM 定义
├── infrastructure/ # 外部依赖适配器(Milvus / Neo4j / MySQL / Redis / 行情源)
├── worker/ # 异步执行进程(与 API 进程分离)
└── static/portal/ # ── View 层(四套前端页面)──
1.2 关键架构决策(代码中已固化的)
| 决策 | 体现位置 | 为什么 |
|---|---|---|
Agent 属于 Service 层,必须有 BaseAgent 骨架 |
app/service/agent/base.py |
AGENTS.md 规则 7;__init_subclass__ 禁止子类覆盖执行顺序 |
| API 与 Worker 是两个独立进程 | app/main.py / app/worker/__main__.py |
受理与执行解耦,可独立扩缩 |
| MySQL 是唯一真相,向量/图是单向投影 | memory_sync_outbox 表 |
投影可随时重建;投影挂掉不影响主流程 |
| 治理审查同步、不落库 | governance.review_output |
治理必须在返回用户前完成,且不能因 DB 故障而跳过 |
二、主要模块与职责
2.1 Agent 底座(最核心)
| 文件 | 职责 | 关键约束 |
|---|---|---|
agent/base.py |
BaseAgent 执行骨架 |
固定顺序:validate_input → validate_access → resolve_config → recall_memory → classify_intent → _execute_governed → governance.review;子类只能实现 _execute_governed |
agent/factory.py |
AgentFactory 按 agent_type 创建 |
每个请求创建一个 Agent 实例,注入治理/模型/工具/意图分类器 |
agent/executor.py |
薄封装,迭代 agent.execute() |
不承载业务逻辑 |
agent/governance.py |
PlatformGovernance.review_output |
四步:引用校验 → 负面词/硬规则拦截 → 脱敏 → 追加强制免责声明 |
agent/authorizer.py |
工具/角色鉴权 | 工具可用范围 = 代码上限 ∩ active config_release 白名单,缺配置则失败关闭 |
agent/bootstrap.py |
唯一组装入口 | @lru_cache(maxsize=1);HTTP 与 Worker 共用;7 个 Agent + 全部工具在此注册 |
7 个已注册 Agent:FundQueryDemoAgent、CustomerServiceAgent、RiskAgent、PlatformProbeAgent、AdvisorAgent、OffsiteFundAgent、PromotionMaterialAgent。
2.2 异步执行与可靠性
| 机制 | 实现 | 要点 |
|---|---|---|
| Outbox 模式 | DomainEventOutbox / MemorySyncOutbox |
持久化 SQL 队列,with_for_update(skip_locked=True);retry_count >= 5 → 死信 |
| Worker 租约围栏 | agent_run_repository.claim() |
每次领单生成新 worker_id = uuid4(),同时围住进程重启;locked_until + 心跳续约 |
| 幂等 | RequestIdempotency + api_request_receipt |
按 (user_id, agent_type, key) 查找;取消路径写 status="failed", error_code="RUN_CANCELLED" |
| 统一错误信封 | main.py 两个异常处理器 |
AgentError → {error:{code,message,retryable,field_errors}, meta:{trace_id}};RequestValidationError → 422 AGENT_INPUT_INVALID |
运行前提(易踩坑):客服/风控对话必须有常驻 Worker(python -m app.worker)。没有 Worker 时 agent_run.status 恒为 queued,前端只显示"响应超时/客服繁忙"——看起来像链路慢,实际是没人处理。
2.3 记忆 / 画像 / 投影(三层)
memory_unit (中期,权威)
↓ 晋升:MIN_EVIDENCE = 2 或 HIGH_CONFIDENCE = 0.90
user_facts (长期,已确认)
↓ 重建
fin_customer_profile + profile_snapshots (画像版本)
↓ 投影(两条 outbox 行共享一个 event_uuid)
Milvus 向量 / Neo4j 图
关键不变量:
profile_snapshots有唯一键uk_profile_snapshot_current→ 必须先is_current=0, current_customer_id=NULL,再插入新 current 行。- Milvus 读写物理隔离:
MilvusKnowledgeClient(读)与MilvusKnowledgeWriter(写)互不 import,双向故障隔离。 - 字段名运行时探测,不得硬编码:Milvus 知识读(
core/knowledge_schema.py)与写(resolve_schema)都把逻辑名(doc_id/content/visibility)映射到环境物理名。
2.4 业务域
| 域 | 关键文件 | 备注 |
|---|---|---|
| 场内交易 | trade_service.py |
行情有效期仅 15 分钟(MAX_QUOTE_AGE),超时所有委托 503且无自动刷新 |
| 行情源 | fund_market_adapter.py |
push2/push2his 域不可达,实际可用源是腾讯(qt.gtimg.cn);费率只能爬 fundf10.eastmoney.com;fetch_nav_history 的分页参数被服务端忽略(固定 20/页) |
| 风控 | risk_scan_service.py |
跨进程排他锁 GET_LOCK('jr_risk_scan_schedule', 0) 必须加在入口层,不能放进 scan() |
| 投顾 | recommendations.py + investment_goals.py |
两类内容寻址键不同:推荐方案用 content_id、方案书用 goal_no |
| 场外/推广 | offsite_fund.py / promotion_material.py |
AGENTS.md 规则 8:不得写入场内交易表 |
| 知识库 | knowledge_management.py |
K002/K003/K004 三端点;上传切成 N 块入库 |
2.5 前端(四套页面)
| 角色 | 路径 | 守卫 |
|---|---|---|
| 访客 | guest/ |
无(用临时访客令牌,仅 agent:run + knowledge:query) |
| 客户 | customer/ |
requireCustomer / requireCustomerOnly |
| 管理员 | employee-console/ |
requireAdmin |
| 风控 | employee-risk/ |
requireRiskStaff |
| 投顾 | employee-advisor/ |
requireAdvisor |
| 运营 | employee-operations/ |
requireOperator |
契约中枢:common/api-client.js 的 ENDPOINTS 冻结表(约 120 条)是端点调用的唯一来源;put()/del() 只表达意图,真实 HTTP 动词仍来自该表。
三、执行流程
3.1 一次 Agent 请求的完整链路
1. 前端 apiClient.post('R001', ...) → 带 X-Trace-ID、Idempotency-Key
2. auth controller / build_request_context → 解析身份 + 权限 + data_scope
3. AgentRunApplicationService.accept()
├─ 幂等检查(RequestIdempotency / api_request_receipt)
├─ 写 agent_run (status='queued')
└─ 写 DomainEventOutbox 事件 'agent.run_requested'
← 立即返回 202 + run_id(不阻塞)
4. 【异步】WorkerRuntime.dispatch_batch() → OutboxWorker.publish_one()
5. WorkerRuntime.run_once() → 选最老的 queued/running/cancel_requested 且租约空闲的 run
6. WorkerRuntime.execute(run_id)
├─ 新 worker_id = uuid4()(围栏)
├─ claim() 条件 UPDATE
├─ _execute_claimed() 任务 + _heartbeat() 心跳任务
7. AgentExecutor → BaseAgent.execute() → 七步骨架
└─ PlatformGovernance.review_output() → 引用校验/拦截/脱敏/免责
8. AgentPersistenceService.complete_run() → 写 status='succeeded' + result
9. 前端轮询 R002(300ms 起,每 500ms,共 40 次 ≈ 20s)或 SSE R003
实测端到端耗时(Worker 在跑 + deepseek-flash):访客一问 4.1–4.8 秒。
3.2 记忆 → 画像的完整链路
对话结束
→ memory.extraction_requested 事件
→ MemoryExtractionService 提取候选记忆单元
→ memory_unit 落库(含 MemoryEvidence,idempotency_key 唯一)
→ [满足阈值] ProfileAssemblyService.promote_facts() → user_facts
→ profile.rebuild_requested 事件 ★
→ ProfileAssemblyService.rebuild() + ProfileGraphProjectionService.project_customer()
→ fin_customer_profile + profile_snapshots(新版本)
→ memory_sync_outbox 写两行(target_store = 'milvus' / 'neo4j',共享同一 event_uuid)
→ WorkerRuntime.consume_profile_projections()
├─ milvus → MilvusProfileProjection
└─ neo4j → ProfileGraphProjectionService(MERGE 幂等)
★ 为什么 profile.rebuild_requested 必须走事件而非内联调用:提取时记忆行还在未提交事务里,内联调用看不到那一行——真实症状是"快照生成了、事实缺失、图里没有边"。
3.3 投影清理(反向链路)
memory.invalidated / memory.deleted 事件
→ ProjectionCleanupService.cleanup()
├─ 删 UserFact
├─ 触发 rebuild
├─ 图 reconcile
└─ Milvus 按 memory_uuid 删除
→ 无 client 时:warn + InteractionAudit(status="skipped_no_client"),仍标记消费
四、关键逻辑(设计意图)
4.1 为什么"工具可用范围"要两层
工具可用范围 = 代码上限 ∩ 当前 active config_release 的发布白名单,缺发布配置则失败关闭。原因是 config_release 是环境数据、不随代码合并——本机 active 版本 id 与架构师环境不同。所以"白名单已发布"必须带环境限定,换环境要重发。
4.2 访客与登录客户走不同工具
| 工具 | 权限码 | 角色 |
|---|---|---|
search_knowledge |
knowledge:reference:read |
customer, advisor, operator, admin |
query_knowledge(别名,同 handler) |
knowledge:query |
visitor, customer |
知识类意图要同时发这两条——缺哪一条,对应人群就一问即失败。
4.3 Worker 组装的三条硬规矩
- 入口不得再起第二个
MemorySyncOutboxWorker—— PR #7 曾有两个消费者用不同neo4jhandler 消费同一队列,导致"同一事实在图里有两种说法"。 OffsiteMailWorker必须共享同一进程 —— 曾有文件覆盖事故删掉了这处接线。consume_profile_projections的 client 为None时拒绝消费 —— 让 Worker 把它判死信,会把"配置缺失"伪装成"投递失败"。
4.4 几个刻意的实现选择
| 选择 | 位置 | 原因 |
|---|---|---|
fetch_kline 用同步 httpx + asyncio.to_thread |
fund_market_adapter.py |
AsyncClient 访问 push2his 会被断开 |
| Milvus 写路径没有"已存在则跳过" | milvus_knowledge_writer.py |
旧向量会继续答旧内容 |
知识检索 _as_row 丢弃无 uuid 的行而不是编一个 |
milvus_adapter.py |
避免产生假引用 |
_with_memory_sources 只在 key 缺失或 None 时回填 |
worker/runtime.py |
格式错误要保持失败关闭 |
advance_clarification 在 >= 10 时拒绝 |
session_repository.py |
澄清轮次上限 |
五、各部分相互关系
┌─────────────────────────────────────┐
│ app/main.py (create_app) │
│ 22 routers + 2 异常处理器 │
└──────────────┬──────────────────────┘
│
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
api/controllers/ api/dependencies/ api/views/envelope
(Controller) (auth/db/rate_limit) (统一信封)
│
▼
service/*_service.py ──► service/agent/ ──► infrastructure/
(Service 业务逻辑) (BaseAgent 骨架) (Milvus/Neo4j/MySQL/行情)
│ │ ▲
▼ ▼ │
repository/*.py ◄────── agent_run / outbox ────────┘
(Model 数据访问)
│
▼
worker/ ◄── 独立进程,消费 Outbox,驱动记忆/画像/投影
依赖方向严格单向:Controller → Service → Repository → Model;infrastructure 只被 Service 调用;worker 依赖 Service 但不被 Service 依赖(除 bootstrap)。
六、疑问、潜在问题与边界情况
🔴 6.1【P0 · 已确证】管理员工作台存在语法错误,整页无法加载
位置:app/static/portal/employee-console/workspace/workspace.js 第 500–540 行
async function submitRule(event, releaseId) 在第 539 行 finally { submit.disabled = false; } 之后缺少闭合的 },导致后续的 loadDriftReviews(L540)等函数被解析为 submitRule 的函数体内容。
我的核查过程(三层,逐层确证):
| 检查方式 | 结果 | 说明 |
|---|---|---|
node --check(逐文件) |
✅ 通过 | 误判——--check 的 ASI 宽容度让它放过 |
真实 ESM 解析(import()) |
❌ SyntaxError: Unexpected end of input |
确证 |
vm.SourceTextModule 扫描全部 44 个模块 |
❌ 仅此一个失败 | 排除其他模块,定位唯一 |
影响:
- 管理员工作台
employee-console/workspace/整页白屏(模块解析期即失败,requireAdmin()都不执行)。 - 连带失效:RBAC 角色查看、配置发布(校验/审核/激活)、模型端点、审计记录、转人工工单、画像候选、投顾复核、知识库管理、画像漂移复核。
- 因为
workspace.js是import(ESM),解析失败不会执行任何一行业务代码,不会降级到任何 fallback。
为什么 CI 没拦住(推断):现有 pytest tests/contract 校验的是 ENDPOINTS 表与页面结构的静态一致性,不做前端模块的真实解析。node --check 若被用作检查手段,则会因 ASI 而漏判。
修复方向(仅建议,未实施):在第 539 行后补一个 }。
🟠 6.2【P1】文本/表格标"未知技术字段"的兜底会掩盖契约漂移
位置:employee-risk/dashboard/dashboard.js 的 FIELD_LABELS / SNAPSHOT_FIELD_LABELS + detailMarkup/snapshotLabel
任何后端新增字段而前端未登记时,界面显示 `${key}(技术字段)` 而不是报错或告警。风险:字段改名或语义变化不会引起任何注意,风控人员会看到"某某(技术字段)"却不知道它是什么。风控是合规敏感域,这里的静默降级值得加一条显式告警。
🟠 6.3【P1】前端 K004 标了 idempotent,但该操作语义上难以幂等
位置:common/api-client.js L96 K004: { method: 'DELETE', path: '/api/v1/knowledge/{knowledgeId}', idempotent: true }
idempotent: true 会让 apiClient 自动附带 Idempotency-Key。对"删除知识"而言,第二次调用与第一次的意图本就相同(都是让该条失效),语义上确实幂等;但若前端因超时重试而换了 key,就可能对同一 knowledgeId 发起两次"失效"——取决于后端是否按 Idempotency-Key 还是按资源状态判幂等。建议在 docs/05 明确该端点的幂等键语义,目前文档里未见说明。
🟠 6.4【P1】RK013–RK015 未登记进 docs/05 §19
位置:api-client.js L60–65 的注释已自述此事
RK013(非流式日报)当前无人调用,仅因"前端契约测试要求这张表里有它"而保留。RK014(SSE 流式)/RK015(邮件)在用但未登记。风险:docs/05 自称"接口唯一权威",此处已出现权威文档与实际端点的偏差——这正好削弱了它作为唯一权威的地位。
🟡 6.5【P2】ADVISOR_GOAL 注册但不可用
位置:api-client.js L79 ADVISOR_GOAL: { GET /api/v1/advisor/investment-goals/current }
页面从不调用它;且对投顾本人访问会返回 404。注释里点明了"注册与调用是两件事"。保留它是为了满足契约测试的表完整性——但这意味着约 120 条端点表里存在"只为测试存在"的条目,后续维护者容易误以为它可用。
🟡 6.6【P2】行情 15 分钟有效期是演示最容易翻的一环
trade_service.py 的 MAX_QUOTE_AGE = 15min,超时后所有委托一律 503「行情已过期」且无自动刷新。补刷需手动跑 python tools/sync_market_prices.py(立即生效、无需重启)。这不是 bug,但这是环境脆弱点:任何演示或验收前若没刷新,交易链路会整体不可用,且错误信息(503)不直接指向"行情过期"这一根因。
🟡 6.7【P2】workspace.js 的 submitRule 里 max_attempts 硬编码为 1
位置:workspace.js L515(在损坏区域附近,但本身逻辑正确)
代码注释已明确:max_attempts 不能超过"端点总数"(主端点 + fallbacks),而界面不编辑 fallbacks(提交空数组),所以只能写 1——写 2 会被后端 422 拒绝。这说明界面能力与后端约束不匹配:用户在界面上无法配置 fallback 端点,模型高可用能力在前端不可达。
🟡 6.8【P2】投影清理在"无 client"时仍标记消费,可能造成静默漂移
位置:worker/runtime.py 的 _cleanup_projection
无 client 时记 InteractionAudit(status="skipped_no_client") 并仍标记消费。这是刻意的(避免把"配置缺失"伪装成"投递失败"),但审计记录需要有人看:如果没人定期检查这些 skipped_no_client,向量库/图里就会积累已失效的记忆,而主流程完全正常。建议:给这类审计加一个可观测指标或定期巡检脚本。
🟡 6.9【P2】历史文档的分类边界需要人工判断
AGENTS.md 已把 docs/04/06/10/13/99 归为 D 类"不要用来判断当前进度",但它们仍在仓库中。这是 2026-09-11 评审的结论(保留成本为零)。风险:新人不会先读 AGENTS.md 的分类表,很可能直接读 docs/04 并采信过期结论。TODO.md 同样如此(5 处"49 张表"实为 51 张业务表)。
🟡 6.10【P2】风控证据表来源枚举与后端一致性依赖人工
位置:employee-risk/dashboard/dashboard.js 的 EVIDENCE_COLUMNS(8 类:customers/products/transactions/capital_flows/holdings/login_records/alerts/notifications)
前端的 EVIDENCE_COLUMNS[state.evidence.source] 是直接索引——若用户提交了不在该枚举里的 source,tableMarkup 的 columns.map 会在 undefined 上抛错。有 delete values.behavior_level 之类的防御,但没有对 source 本身的校验。建议确认后端 RK004 的路径参数枚举是否与前端的 8 类完全一致。
✅ 6.11 已核查、确认无问题的项(避免误报)
| 疑点 | 核查结论 |
|---|---|
A038 前端期望 identity 结构 vs rbac.py 的 /users/{user_id}/roles |
一致。rbac.py 前缀同为 /api/v1/admin,get_user_identity() 返回 envelope(data),data 含 username/user_no/permissions/data_scope/roles |
K002/K003 为何标 raw: true |
正确且必要。其成功体是裸的 {knowledge_ids,...} / {items,count},无 data 信封。不标会导致上传后显示"已入库 0 块"而 DB 其实正常 |
A048/A049 存在的必要性 |
必要。PUT 需要 If-Match etag,而列表 meta 不携带 etag,必须先取详情 |
V001 为何 raw: true |
正确。访客令牌接口返回裸 token |
| 全部 44 个前端模块的语法 | 仅 workspace.js 一个失败,其余 43 个全部通过真实 ESM 解析 |
七、值得注意的边界情况(速查)
- 行情:15 分钟过期 → 全部委托 503,需手动补刷。
- Worker:不在跑 →
agent_run恒为queued,前端显示"超时/繁忙"。 config_release:环境数据,换环境必须重发白名单。- RBAC 种子:
tools/seed_test_rbac.py是 DELETE 重建语义,没并进去的权限码重建一次就没了,表现是"接口突然 403"而无任何报错线索。 - Milvus schema:环境而异,检索层已运行时探测;不得硬编码字段名。
- 澄清轮次:
advance_clarification在>= 10拒绝。 profile_snapshots:必须先清 current 再插新行。- Outbox 枚举值:
target_store/operation/status必须小写。 - 时区:DB session 通过 asyncmy
init_command设SET time_zone = '+00:00';业务计算用local_hour/local_date。 start.ps1:必须存为 UTF-8 with BOM,否则 Windows PowerShell 5.1 按 GBK 解析中文注释会语法报错。
八、附:核查方法与可复现命令
# 1. 确证 workspace.js 语法错误(node --check 会漏判,必须用真实解析)
node -e "import('file:///C:/Users/Windows/Desktop/项目代码/app/static/portal/employee-console/workspace/workspace.js').catch(e=>console.log(e.constructor.name, e.message))"
# 2. 全量扫描前端模块(仅 workspace.js 失败)
# 见本报告 §6.1 表格中的 vm.SourceTextModule 方式
# 3. 后端静态检查
mypy app # 249 files, 0 errors
pytest tests/unit tests/contract # 1376 passed
pytest tests/integration # 104 passed
# 4. 交付自检(两条线互补)
python tools/e2e_smoke_test.py # 业务链路冒烟,6 条线 40 项
python tools/portal_api_check.py # 接口契约体检,41 项
报告结束。本次分析全程只读,未修改任何代码或既有文档。