3 次代碼提交 638d89b51e ... 75fdcd7fa1

作者 SHA1 備註 提交日期
  2643616413 75fdcd7fa1 python端接口开发 5 天之前
  2643616413 ec44b99e5a Merge branch 'master' of https://git.yangzhiqiang.tech/zsh/finrep-report 1 周之前
  2643616413 e57abd8145 Initial commit 1 周之前
共有 100 個文件被更改,包括 2853 次插入167 次删除
  1. 3 1
      .gitignore
  2. 185 166
      STRUCTURE.md
  3. 23 0
      algo/.env
  4. 63 0
      algo/README.md
  5. 16 0
      algo/deploy/Dockerfile
  6. 29 0
      algo/env.example
  7. 33 0
      algo/pyproject.toml
  8. 81 0
      algo/src/finrep_algo_agent.egg-info/PKG-INFO
  9. 56 0
      algo/src/finrep_algo_agent.egg-info/SOURCES.txt
  10. 1 0
      algo/src/finrep_algo_agent.egg-info/dependency_links.txt
  11. 12 0
      algo/src/finrep_algo_agent.egg-info/requires.txt
  12. 1 0
      algo/src/finrep_algo_agent.egg-info/top_level.txt
  13. 3 0
      algo/src/finrep_algo_agent/__init__.py
  14. 二進制
      algo/src/finrep_algo_agent/__pycache__/__init__.cpython-312.pyc
  15. 二進制
      algo/src/finrep_algo_agent/__pycache__/main.cpython-312.pyc
  16. 1 0
      algo/src/finrep_algo_agent/api/__init__.py
  17. 二進制
      algo/src/finrep_algo_agent/api/__pycache__/__init__.cpython-312.pyc
  18. 二進制
      algo/src/finrep_algo_agent/api/__pycache__/deps.cpython-312.pyc
  19. 35 0
      algo/src/finrep_algo_agent/api/deps.py
  20. 7 0
      algo/src/finrep_algo_agent/api/routers/__init__.py
  21. 二進制
      algo/src/finrep_algo_agent/api/routers/__pycache__/__init__.cpython-312.pyc
  22. 二進制
      algo/src/finrep_algo_agent/api/routers/__pycache__/debug.cpython-312.pyc
  23. 二進制
      algo/src/finrep_algo_agent/api/routers/__pycache__/health.cpython-312.pyc
  24. 二進制
      algo/src/finrep_algo_agent/api/routers/__pycache__/outline.cpython-312.pyc
  25. 二進制
      algo/src/finrep_algo_agent/api/routers/__pycache__/rag.cpython-312.pyc
  26. 二進制
      algo/src/finrep_algo_agent/api/routers/__pycache__/section.cpython-312.pyc
  27. 68 0
      algo/src/finrep_algo_agent/api/routers/debug.py
  28. 12 0
      algo/src/finrep_algo_agent/api/routers/health.py
  29. 25 0
      algo/src/finrep_algo_agent/api/routers/outline.py
  30. 130 0
      algo/src/finrep_algo_agent/api/routers/rag.py
  31. 22 0
      algo/src/finrep_algo_agent/api/routers/section.py
  32. 3 0
      algo/src/finrep_algo_agent/config/__init__.py
  33. 二進制
      algo/src/finrep_algo_agent/config/__pycache__/__init__.cpython-312.pyc
  34. 二進制
      algo/src/finrep_algo_agent/config/__pycache__/settings.cpython-312.pyc
  35. 49 0
      algo/src/finrep_algo_agent/config/settings.py
  36. 3 0
      algo/src/finrep_algo_agent/llm/__init__.py
  37. 二進制
      algo/src/finrep_algo_agent/llm/__pycache__/__init__.cpython-312.pyc
  38. 二進制
      algo/src/finrep_algo_agent/llm/__pycache__/client.cpython-312.pyc
  39. 二進制
      algo/src/finrep_algo_agent/llm/client/__pycache__/client.cpython-312.pyc
  40. 163 0
      algo/src/finrep_algo_agent/llm/client/client.py
  41. 44 0
      algo/src/finrep_algo_agent/main.py
  42. 11 0
      algo/src/finrep_algo_agent/prompts/__init__.py
  43. 二進制
      algo/src/finrep_algo_agent/prompts/__pycache__/__init__.cpython-312.pyc
  44. 二進制
      algo/src/finrep_algo_agent/prompts/__pycache__/builders.cpython-312.pyc
  45. 174 0
      algo/src/finrep_algo_agent/prompts/builders.py
  46. 192 0
      algo/src/finrep_algo_agent/prompts/templates/outline_l1.j2
  47. 314 0
      algo/src/finrep_algo_agent/prompts/templates/outline_l2.j2
  48. 52 0
      algo/src/finrep_algo_agent/prompts/templates/section_analysis.j2
  49. 51 0
      algo/src/finrep_algo_agent/prompts/templates/section_info.j2
  50. 53 0
      algo/src/finrep_algo_agent/prompts/templates/section_judgment.j2
  51. 53 0
      algo/src/finrep_algo_agent/prompts/templates/section_metric.j2
  52. 1 0
      algo/src/finrep_algo_agent/rag/__init__.py
  53. 二進制
      algo/src/finrep_algo_agent/rag/__pycache__/__init__.cpython-312.pyc
  54. 4 0
      algo/src/finrep_algo_agent/rag/ingestion/__init__.py
  55. 二進制
      algo/src/finrep_algo_agent/rag/ingestion/__pycache__/__init__.cpython-312.pyc
  56. 二進制
      algo/src/finrep_algo_agent/rag/ingestion/__pycache__/chunking.cpython-312.pyc
  57. 二進制
      algo/src/finrep_algo_agent/rag/ingestion/__pycache__/file_extract.cpython-312.pyc
  58. 29 0
      algo/src/finrep_algo_agent/rag/ingestion/chunking.py
  59. 57 0
      algo/src/finrep_algo_agent/rag/ingestion/file_extract.py
  60. 4 0
      algo/src/finrep_algo_agent/rag/retrieval/__init__.py
  61. 二進制
      algo/src/finrep_algo_agent/rag/retrieval/__pycache__/__init__.cpython-312.pyc
  62. 二進制
      algo/src/finrep_algo_agent/rag/retrieval/__pycache__/formatting.cpython-312.pyc
  63. 二進制
      algo/src/finrep_algo_agent/rag/retrieval/__pycache__/similarity.cpython-312.pyc
  64. 21 0
      algo/src/finrep_algo_agent/rag/retrieval/formatting.py
  65. 18 0
      algo/src/finrep_algo_agent/rag/retrieval/similarity.py
  66. 3 0
      algo/src/finrep_algo_agent/rag/vectorstore/__init__.py
  67. 二進制
      algo/src/finrep_algo_agent/rag/vectorstore/__pycache__/__init__.cpython-312.pyc
  68. 二進制
      algo/src/finrep_algo_agent/rag/vectorstore/__pycache__/store.cpython-312.pyc
  69. 42 0
      algo/src/finrep_algo_agent/rag/vectorstore/store.py
  70. 43 0
      algo/src/finrep_algo_agent/schemas/__init__.py
  71. 二進制
      algo/src/finrep_algo_agent/schemas/__pycache__/__init__.cpython-312.pyc
  72. 二進制
      algo/src/finrep_algo_agent/schemas/__pycache__/outline.cpython-312.pyc
  73. 二進制
      algo/src/finrep_algo_agent/schemas/__pycache__/rag.cpython-312.pyc
  74. 二進制
      algo/src/finrep_algo_agent/schemas/__pycache__/section.cpython-312.pyc
  75. 90 0
      algo/src/finrep_algo_agent/schemas/contracts.py
  76. 136 0
      algo/src/finrep_algo_agent/schemas/outline.py
  77. 78 0
      algo/src/finrep_algo_agent/schemas/rag.py
  78. 41 0
      algo/src/finrep_algo_agent/schemas/section.py
  79. 6 0
      algo/src/finrep_algo_agent/skills/__init__.py
  80. 二進制
      algo/src/finrep_algo_agent/skills/__pycache__/__init__.cpython-312.pyc
  81. 二進制
      algo/src/finrep_algo_agent/skills/__pycache__/outline_l1.cpython-312.pyc
  82. 二進制
      algo/src/finrep_algo_agent/skills/__pycache__/outline_l2.cpython-312.pyc
  83. 二進制
      algo/src/finrep_algo_agent/skills/__pycache__/section_gen.cpython-312.pyc
  84. 0 0
      algo/src/finrep_algo_agent/skills/outline_l1/.gitkeep
  85. 3 0
      algo/src/finrep_algo_agent/skills/outline_l1/__init__.py
  86. 二進制
      algo/src/finrep_algo_agent/skills/outline_l1/__pycache__/__init__.cpython-312.pyc
  87. 二進制
      algo/src/finrep_algo_agent/skills/outline_l1/__pycache__/outline_l1.cpython-312.pyc
  88. 78 0
      algo/src/finrep_algo_agent/skills/outline_l1/outline_l1.py
  89. 0 0
      algo/src/finrep_algo_agent/skills/outline_l2/.gitkeep
  90. 3 0
      algo/src/finrep_algo_agent/skills/outline_l2/__init__.py
  91. 二進制
      algo/src/finrep_algo_agent/skills/outline_l2/__pycache__/__init__.cpython-312.pyc
  92. 二進制
      algo/src/finrep_algo_agent/skills/outline_l2/__pycache__/outline_l2.cpython-312.pyc
  93. 77 0
      algo/src/finrep_algo_agent/skills/outline_l2/outline_l2.py
  94. 3 0
      algo/src/finrep_algo_agent/skills/rag_retrieve/__init__.py
  95. 二進制
      algo/src/finrep_algo_agent/skills/rag_retrieve/__pycache__/__init__.cpython-312.pyc
  96. 二進制
      algo/src/finrep_algo_agent/skills/rag_retrieve/__pycache__/rag_service.cpython-312.pyc
  97. 145 0
      algo/src/finrep_algo_agent/skills/rag_retrieve/rag_service.py
  98. 0 0
      algo/src/finrep_algo_agent/skills/section_gen/.gitkeep
  99. 3 0
      algo/src/finrep_algo_agent/skills/section_gen/__init__.py
  100. 二進制
      algo/src/finrep_algo_agent/skills/section_gen/__pycache__/__init__.cpython-312.pyc

+ 3 - 1
.gitignore

@@ -1 +1,3 @@
-.idea
+.idea
+
+algo/.venv/

+ 185 - 166
STRUCTURE.md

@@ -1,166 +1,185 @@
-# finrep-report 工程目录说明
-
-本仓库为 **单仓(Monorepo)**,聚合 **Web 前端**、**Java 业务后端**、**Python 算法 / Agent** 三端。前端 **只调用 Java 后端**;Java 通过 **pythonagent** 适配层调用 **algo** 服务。
-
-**约定**:Java 根包名为 `com.yuxin.finrep`;Python 包根为 `finrep_algo_agent`。目录中当前多为占位(`.gitkeep`),后续可在此结构上添加 `package.json`、`pom.xml`、`pyproject.toml` 等。
-
----
-
-## 一、`frontend/`(Web 前端)
-
-面向业务人员的 **浏览器 SPA**(建议技术栈:React + 企业级组件库)。**不直接请求算法端**;统一走 `backend` 提供的 HTTPS API 与 SSE。
-
-| 子目录 | 作用 |
-|--------|------|
-| **`public/`** | 不参与打包的静态资源:如 `favicon.ico`、`robots.txt`、固定可缓存的公共文件等。 |
-| **`src/assets/`** | 需经构建处理的资源:图片、图标、小号字体等(由打包工具优化路径与哈希)。 |
-| **`src/components/`** | 可复用 UI 组件;其中 **`common/`** 放置与设计系统无关的通用控件(按钮封装、空状态、加载中等)。业务强相关大块可再建 `features/` 等子目录(按需扩展)。 |
-| **`src/layouts/`** | 整体布局壳:顶栏、侧栏、内容区组合;各业务页挂载在布局之下。 |
-| **`src/pages/`** | **页面级**入口:与路由一一对应,例如任务列表、任务向导、大纲确认树、数据确认、报告预览与导出等。 |
-| **`src/routes/`** | 路由表、路由守卫(登录态、权限)、懒加载页面配置。 |
-| **`src/services/`** | **HTTP / SSE 客户端封装**:调用 Java 的 REST、订阅任务进度;集中处理 baseURL、Token、错误码与 TraceId 头(若规范要求)。 |
-| **`src/hooks/`** | React Hooks:如任务轮询、SSE 连接、表单与向导步骤状态等与 UI 紧耦合的复用逻辑。 |
-| **`src/stores/`** | 全局或跨页状态(Redux、Zustand、Recoil 等选型待定):当前任务、用户信息、租户上下文等。 |
-| **`src/utils/`** | 纯函数工具:日期格式化、树数据转换、与后端 DTO 对齐的轻量映射等。 |
-| **`src/styles/`** | 全局样式、CSS 变量、主题 token;与组件库主题对齐。 |
-| **`src/types/`** | TypeScript 类型:与后端 OpenAPI 生成或手写对齐的请求 / 响应、枚举、领域 DTO。 |
-| **`tests/`** | 前端单测 / 组件测(如 Vitest + Testing Library);与 `src` 目录结构可镜像或按需组织。 |
-
----
-
-## 二、`backend/`(Java 业务后端)
-
-**Spring Boot 多模块** 结构:承载 **鉴权、多租户、任务状态机、落库、行内接口、OSS、导出**,以及 **调用算法服务的 HTTP/gRPC 客户端**。
-
-### 2.1 模块总览
-
-| 模块 | 作用 |
-|------|------|
-| **`finrep-parent`** | 父 POM(后续添加):统一依赖版本、插件、子模块列表。 |
-| **`finrep-common`** | 跨模块通用代码:上下文、异常、工具、常量。 |
-| **`finrep-domain`** | 领域模型与领域语义分包(任务、大纲、数据准备、生成等),**不依赖** Web 与具体持久化框架(或仅依赖极少注解,按团队规约)。 |
-| **`finrep-application`** | 应用服务层:**编排 / 状态机推进**、用例(command/query)、**入站/出站端口接口**、面向产品的策略(如选默认模型策略的 Java 侧部分)。 |
-| **`finrep-infrastructure`** | **落地实现**:数据库、Redis、MQ、对象存储、外部 HTTP、**pythonagent**、可观测性配置等。 |
-| **`finrep-web`** | **对外 HTTP/SSE**:REST 控制器、请求/响应 DTO、安全、全局异常、WebMvc 配置。 |
-| **`finrep-worker`** | **异步消费者**(与 MQ 配合):长耗时阶段消费、分布式锁、可选定时补偿。 |
-| **`finrep-contract`** | **OpenAPI 等契约**:`openapi/` 下放 YAML/JSON,可供前后端与 Python 对齐(与 Java 代码生成配置配合使用)。 |
-| **`docs/`** | 后端专项说明(部署参数、环境约定等),与仓库根目录产品文档区分。 |
-
-### 2.2 `finrep-common` 包路径 `.../common/`
-
-| 包 | 作用 |
-|-----|------|
-| **`context`** | `TenantContext`、`UserContext`、traceId 等在请求链路中的承载与 ThreadLocal/传递封装。 |
-| **`exception`** | 业务异常基类、错误码枚举;与 `web` 层统一异常 advice 对应。 |
-| **`util`** | 通用工具类(字符串、集合、JSON 辅助等)。 |
-| **`constant`** | 全局常量、HTTP 头名称约定、状态枚举与持久化一致时集中定义(或与 domain 协同)。 |
-
-### 2.3 `finrep-domain` 包路径 `.../domain/`
-
-| 包 | 作用 |
-|-----|------|
-| **`task`** | 报告任务聚合、任务生命周期、状态机枚举(领域语义层面)。 |
-| **`outline`** | 一级/二级大纲、最终知识单元清单等领域对象与规则(不含 HTTP 与 ORM)。 |
-| **`dataprep`** | 知识单元数据包、数据确认等领域概念。 |
-| **`generation`** | 单段生成结果、完整报告等领域概念。 |
-| **`knowledge`** | 与「知识单元 / 适用范围」相关的领域类型(若与预置配置边界相关)。 |
-| **`tenant`** | 租户/组织值对象、隔离相关领域规则。 |
-| **`shared`** | 域内多处共用的值对象、标识符类型等。 |
-
-### 2.4 `finrep-application` 包路径 `.../application/`
-
-| 包 | 作用 |
-|-----|------|
-| **`orchestration`** | **主链路编排**:阶段推进、与人工卡点协作、调用出站端口(持久化、MQ、算法客户端)。 |
-| **`command`** | 写用例:创建任务、提交大纲确认、提交数据确认、触发生成等。 |
-| **`query`** | 读用例:任务详情、大纲草稿、数据包展示、报告预览数据等。 |
-| **`service`** | 复杂用例实现类、跨聚合协调(若与 command/query 拆分并行存在)。 |
-| **`policy`** | 应用层策略:例如 Java 侧路由到不同算法接口、租户级功能开关等。 |
-| **`port/in`** | **入站端口**:由 `web` 或 `worker` 调用的应用服务接口。 |
-| **`port/out`** | **出站端口**:持久化、缓存、消息、**算法服务**、OSS 等由 `infrastructure` 实现的接口定义。 |
-
-### 2.5 `finrep-infrastructure` 包路径 `.../infrastructure/`
-
-| 包 | 作用 |
-|-----|------|
-| **`persistence/entity`** | JPA Entity / 或 MyBatis PO 等,与表结构对应。 |
-| **`persistence/repository`** | 仓储实现:封装 SQL / JPA 访问。 |
-| **`persistence/mapper`** | MyBatis Mapper 接口与 XML(若选用 MyBatis)。 |
-| **`redis`** | 分布式锁、缓存、限流计数、任务进度缓存等。 |
-| **`mq/producer`** | 投递异步任务消息。 |
-| **`mq/consumer`** | 若部分消费逻辑共享;**也可**仅放在 `finrep-worker` 由团队二选一。 |
-| **`mq/message`** | 消息体 DTO、Topic/Tag 常量。 |
-| **`integration/http`** | 调用**行内** HTTP API(工商、风险、通用网关等)。 |
-| **`integration/metrics`** | **指标平台**或统一数据服务客户端。 |
-| **`integration/external`** | 其他外部系统适配(与 metrics/http 并列归类)。 |
-| **`integration/pythonagent`** | **调用 `algo`(Python)** 的客户端:OpenAPI 生成或手写 WebClient/Feign;统一超时、重试、mTLS/Token、TraceId 传递。 |
-| **`storage/oss`** | 对象存储上传/下载/预签名 URL。 |
-| **`config`** | Bean 装配、多数据源、RestTemplate/WebClient 工厂等。 |
-| **`observability`** | 日志 MDC、`MeterRegistry`、Tracing 与 Java 侧埋点。 |
-| **`resources/db/migration`** | Flyway / Liquibase 脚本目录。 |
-
-### 2.6 `finrep-web` 包路径 `.../web/`
-
-| 包 | 作用 |
-|-----|------|
-| **`rest/dto`** | 入参/出参对象;与 OpenAPI 一致。 |
-| **`rest/advice`** | 全局异常、统一响应包装、参数校验错误映射。 |
-| **`sse`** | **Server-Sent Events**:向浏览器推送任务阶段、当前知识单元进度等。 |
-| **`security`** | Spring Security:认证、鉴权、从令牌解析租户/用户。 |
-| **`config`** | Web 层专属配置:CORS、国际化、Jackson 等。 |
-
-**`resources/static` / `templates`**:若需服务端薄模板或静态页(多数 SPA 仅 `static` 放空由 Nginx 托管前端构建产物)。
-
-### 2.7 `finrep-worker` 包路径 `.../worker/`
-
-| 包 | 作用 |
-|-----|------|
-| **`consumer`** | MQ 监听入口:收到消息后调用 `application` 层推进或调用算法。 |
-| **`handler`** | 按消息类型拆分的处理逻辑(大纲生成、全文生成批次等)。 |
-| **`scheduler`** | 可选 `@Scheduled`:重试、超时扫描、清理临时数据。 |
-| **`config`** | Worker 进程专属配置(线程池、并发度等)。 |
-
----
-
-## 三、`algo/`(Python 算法 / Agent 服务)
-
-**FastAPI(建议)** 对内提供 **OpenAI 兼容** 大模型调用与 **报告专用技能**:一级/二级大纲、段落生成、RAG 检索等。由 **`backend`** 内网调用;**不对公网暴露**(或与网关结合白名单)。
-
-| 子目录 | 作用 |
-|--------|------|
-| **`src/finrep_algo_agent/api/routers`** | HTTP 路由:按技能或资源划分 `POST /v1/outline/l1` 等接口定义。 |
-| **`src/finrep_algo_agent/api/deps`** | FastAPI `Depends`:鉴权、限流、公共请求体解析、从 Header 注入 trace/tenant。 |
-| **`src/finrep_algo_agent/agent`** | 多步编排入口:LangGraph 或状态图定义、Human-in-the-loop **占位**(实际卡点仍在 Java/库表时,此处仅接收已确认上下文)。 |
-| **`src/finrep_algo_agent/skills/outline_l1`** | **一级大纲**技能:拼装 Prompt、调模型、解析/校验 JSON、返回结构化一级章节结果。 |
-| **`src/finrep_algo_agent/skills/outline_l2`** | **二级大纲 / 知识单元装配**技能:按章调用、章节编号结构、与需求文档 JSON 契约对齐。 |
-| **`src/finrep_algo_agent/skills/section_gen`** | **按知识单元生成正文**技能:模板变量注入、段落级调用与拼接顺序由 Java 调多次或一次批处理(按设计定)。 |
-| **`src/finrep_algo_agent/skills/rag_retrieve`** | **仅检索**:返回引用片段供上游拼 Prompt;或由 `services` 组合进 `section_gen`。 |
-| **`src/finrep_algo_agent/llm/client`** | OpenAI 兼容 HTTP 客户端封装(base_url、api_key、超时)。 |
-| **`src/finrep_algo_agent/llm/router`** | **多模型路由**:按请求元数据选择模型别名、主备降级。 |
-| **`src/finrep_algo_agent/llm/providers`** | 各厂商/集群差异适配(若路由不足以覆盖)。 |
-| **`src/finrep_algo_agent/rag/ingestion`** | 文档解析、分块、写入向量库(离线或准实时任务)。 |
-| **`src/finrep_algo_agent/rag/retrieval`** | 查询改写、相似度检索、重排序(若需要)。 |
-| **`src/finrep_algo_agent/rag/vectorstore`** | 向量库访问封装(pgvector、Milvus 等)。 |
-| **`src/finrep_algo_agent/prompts/templates`** | Jinja2 / 文本模板文件;与需求文档章节结构对齐的版本管理。 |
-| **`src/finrep_algo_agent/prompts/builders`** | 从领域 DTO 生成 messages 列表的构建器代码。 |
-| **`src/finrep_algo_agent/schemas`** | Pydantic 模型:请求/响应、大纲 JSON、与 Java DTO 命名对齐说明。 |
-| **`src/finrep_algo_agent/services`** | 组合技能与仓储的高层服务;供 `routers` 薄层调用。 |
-| **`src/finrep_algo_agent/core`** | 领域无关内核:常量、共用异常、运行模式枚举等。 |
-| **`src/finrep_algo_agent/config`** | `pydantic-settings` / 环境变量:模型 URL、密钥(来自密钥管理)、功能开关。 |
-| **`src/finrep_algo_agent/observability`** | 日志结构化、Metrics、与 OpenTelemetry 对接。 |
-| **`tests/unit`** | 单测:解析、Prompt 构建、mock LLM。 |
-| **`tests/integration`** | 集成测:需真实或 testcontainers 模型/向量库时启用。 |
-| **`scripts/`** | 一次性脚本:索引重建、批量评测、Prompt 回归等。 |
-| **`deploy/`** | Dockerfile、k8s YAML、行内发布说明。 |
-| **`docs/`** | 算法侧设计:接口契约、与 Java 对齐字段、模型版本记录。 |
-
----
-
-## 四、三端协作关系(回顾)
-
-```text
-浏览器 → frontend → backend(REST + SSE)
-                    → backend.integration.pythonagent → algo(REST)
-```
-
-任务与人工确认的 **权威状态** 在 **backend 数据库**;`algo` 尽量 **无状态或可重建**,输出结构化结果供 Java 持久化。
+# finrep-report 工程目录说明
+
+本仓库为 **单仓(Monorepo)**,聚合 **Web 前端**、**Java 业务后端**、**Python 算法 / Agent** 三端。前端 **只调用 Java 后端**;Java 通过 **pythonagent** 适配层调用 **algo** 服务。
+
+**约定**:Java 根包名为 `com.yuxin.finrep`;Python 包根为 `finrep_algo_agent`。目录中当前多为占位(`.gitkeep`),后续可在此结构上添加 `package.json`、`pom.xml`、`pyproject.toml` 等。
+
+---
+
+## 一、`frontend/`(Web 前端)
+
+面向业务人员的 **浏览器 SPA**(建议技术栈:React + 企业级组件库)。**不直接请求算法端**;统一走 `backend` 提供的 HTTPS API 与 SSE。
+
+
+| 子目录                   | 作用                                                                                          |
+| --------------------- | ------------------------------------------------------------------------------------------- |
+| `**public/`**         | 不参与打包的静态资源:如 `favicon.ico`、`robots.txt`、固定可缓存的公共文件等。                                        |
+| `**src/assets/**`     | 需经构建处理的资源:图片、图标、小号字体等(由打包工具优化路径与哈希)。                                                        |
+| `**src/components/**` | 可复用 UI 组件;其中 `**common/**` 放置与设计系统无关的通用控件(按钮封装、空状态、加载中等)。业务强相关大块可再建 `features/` 等子目录(按需扩展)。 |
+| `**src/layouts/**`    | 整体布局壳:顶栏、侧栏、内容区组合;各业务页挂载在布局之下。                                                              |
+| `**src/pages/**`      | **页面级**入口:与路由一一对应,例如任务列表、任务向导、大纲确认树、数据确认、报告预览与导出等。                                          |
+| `**src/routes/`**     | 路由表、路由守卫(登录态、权限)、懒加载页面配置。                                                                   |
+| `**src/services/**`   | **HTTP / SSE 客户端封装**:调用 Java 的 REST、订阅任务进度;集中处理 baseURL、Token、错误码与 TraceId 头(若规范要求)。        |
+| `**src/hooks/`**      | React Hooks:如任务轮询、SSE 连接、表单与向导步骤状态等与 UI 紧耦合的复用逻辑。                                           |
+| `**src/stores/**`     | 全局或跨页状态(Redux、Zustand、Recoil 等选型待定):当前任务、用户信息、租户上下文等。                                       |
+| `**src/utils/**`      | 纯函数工具:日期格式化、树数据转换、与后端 DTO 对齐的轻量映射等。                                                         |
+| `**src/styles/**`     | 全局样式、CSS 变量、主题 token;与组件库主题对齐。                                                              |
+| `**src/types/**`      | TypeScript 类型:与后端 OpenAPI 生成或手写对齐的请求 / 响应、枚举、领域 DTO。                                        |
+| `**tests/**`          | 前端单测 / 组件测(如 Vitest + Testing Library);与 `src` 目录结构可镜像或按需组织。                                |
+
+
+---
+
+## 二、`backend/`(Java 业务后端)
+
+**Spring Boot 多模块** 结构:承载 **鉴权、多租户、任务状态机、落库、行内接口、OSS、导出**,以及 **调用算法服务的 HTTP/gRPC 客户端**。
+
+### 2.1 模块总览
+
+
+| 模块                          | 作用                                                                                |
+| --------------------------- | --------------------------------------------------------------------------------- |
+| `**finrep-parent`**         | 父 POM(后续添加):统一依赖版本、插件、子模块列表。                                                      |
+| `**finrep-common**`         | 跨模块通用代码:上下文、异常、工具、常量。                                                             |
+| `**finrep-domain**`         | 领域模型与领域语义分包(任务、大纲、数据准备、生成等),**不依赖** Web 与具体持久化框架(或仅依赖极少注解,按团队规约)。                 |
+| `**finrep-application`**    | 应用服务层:**编排 / 状态机推进**、用例(command/query)、**入站/出站端口接口**、面向产品的策略(如选默认模型策略的 Java 侧部分)。 |
+| `**finrep-infrastructure`** | **落地实现**:数据库、Redis、MQ、对象存储、外部 HTTP、**pythonagent**、可观测性配置等。                       |
+| `**finrep-web`**            | **对外 HTTP/SSE**:REST 控制器、请求/响应 DTO、安全、全局异常、WebMvc 配置。                             |
+| `**finrep-worker`**         | **异步消费者**(与 MQ 配合):长耗时阶段消费、分布式锁、可选定时补偿。                                           |
+| `**finrep-contract`**       | **OpenAPI 等契约**:`openapi/` 下放 YAML/JSON,可供前后端与 Python 对齐(与 Java 代码生成配置配合使用)。      |
+| `**docs/`**                 | 后端专项说明(部署参数、环境约定等),与仓库根目录产品文档区分。                                                  |
+
+
+### 2.2 `finrep-common` 包路径 `.../common/`
+
+
+| 包               | 作用                                                                  |
+| --------------- | ------------------------------------------------------------------- |
+| `**context**`   | `TenantContext`、`UserContext`、traceId 等在请求链路中的承载与 ThreadLocal/传递封装。 |
+| `**exception**` | 业务异常基类、错误码枚举;与 `web` 层统一异常 advice 对应。                               |
+| `**util**`      | 通用工具类(字符串、集合、JSON 辅助等)。                                             |
+| `**constant**`  | 全局常量、HTTP 头名称约定、状态枚举与持久化一致时集中定义(或与 domain 协同)。                      |
+
+
+### 2.3 `finrep-domain` 包路径 `.../domain/`
+
+
+| 包                | 作用                                       |
+| ---------------- | ---------------------------------------- |
+| `**task**`       | 报告任务聚合、任务生命周期、状态机枚举(领域语义层面)。             |
+| `**outline**`    | 一级/二级大纲、最终知识单元清单等领域对象与规则(不含 HTTP 与 ORM)。 |
+| `**dataprep**`   | 知识单元数据包、数据确认等领域概念。                       |
+| `**generation**` | 单段生成结果、完整报告等领域概念。                        |
+| `**knowledge**`  | 与「知识单元 / 适用范围」相关的领域类型(若与预置配置边界相关)。       |
+| `**tenant**`     | 租户/组织值对象、隔离相关领域规则。                       |
+| `**shared**`     | 域内多处共用的值对象、标识符类型等。                       |
+
+
+### 2.4 `finrep-application` 包路径 `.../application/`
+
+
+| 包                   | 作用                                                           |
+| ------------------- | ------------------------------------------------------------ |
+| `**orchestration**` | **主链路编排**:阶段推进、与人工卡点协作、调用出站端口(持久化、MQ、算法客户端)。                 |
+| `**command`**       | 写用例:创建任务、提交大纲确认、提交数据确认、触发生成等。                                |
+| `**query**`         | 读用例:任务详情、大纲草稿、数据包展示、报告预览数据等。                                 |
+| `**service**`       | 复杂用例实现类、跨聚合协调(若与 command/query 拆分并行存在)。                      |
+| `**policy**`        | 应用层策略:例如 Java 侧路由到不同算法接口、租户级功能开关等。                           |
+| `**port/in**`       | **入站端口**:由 `web` 或 `worker` 调用的应用服务接口。                       |
+| `**port/out`**      | **出站端口**:持久化、缓存、消息、**算法服务**、OSS 等由 `infrastructure` 实现的接口定义。 |
+
+
+### 2.5 `finrep-infrastructure` 包路径 `.../infrastructure/`
+
+
+| 包                             | 作用                                                                                      |
+| ----------------------------- | --------------------------------------------------------------------------------------- |
+| `**persistence/entity`**      | JPA Entity / 或 MyBatis PO 等,与表结构对应。                                                     |
+| `**persistence/repository**`  | 仓储实现:封装 SQL / JPA 访问。                                                                   |
+| `**persistence/mapper**`      | MyBatis Mapper 接口与 XML(若选用 MyBatis)。                                                    |
+| `**redis**`                   | 分布式锁、缓存、限流计数、任务进度缓存等。                                                                   |
+| `**mq/producer**`             | 投递异步任务消息。                                                                               |
+| `**mq/consumer**`             | 若部分消费逻辑共享;**也可**仅放在 `finrep-worker` 由团队二选一。                                             |
+| `**mq/message`**              | 消息体 DTO、Topic/Tag 常量。                                                                   |
+| `**integration/http**`        | 调用**行内** HTTP API(工商、风险、通用网关等)。                                                         |
+| `**integration/metrics`**     | **指标平台**或统一数据服务客户端。                                                                     |
+| `**integration/external`**    | 其他外部系统适配(与 metrics/http 并列归类)。                                                          |
+| `**integration/pythonagent**` | **调用 `algo`(Python)** 的客户端:OpenAPI 生成或手写 WebClient/Feign;统一超时、重试、mTLS/Token、TraceId 传递。 |
+| `**storage/oss`**             | 对象存储上传/下载/预签名 URL。                                                                      |
+| `**config**`                  | Bean 装配、多数据源、RestTemplate/WebClient 工厂等。                                                |
+| `**observability**`           | 日志 MDC、`MeterRegistry`、Tracing 与 Java 侧埋点。                                              |
+| `**resources/db/migration**`  | Flyway / Liquibase 脚本目录。                                                                |
+
+
+### 2.6 `finrep-web` 包路径 `.../web/`
+
+
+| 包                 | 作用                                           |
+| ----------------- | -------------------------------------------- |
+| `**rest/dto**`    | 入参/出参对象;与 OpenAPI 一致。                        |
+| `**rest/advice**` | 全局异常、统一响应包装、参数校验错误映射。                        |
+| `**sse**`         | **Server-Sent Events**:向浏览器推送任务阶段、当前知识单元进度等。 |
+| `**security`**    | Spring Security:认证、鉴权、从令牌解析租户/用户。            |
+| `**config**`      | Web 层专属配置:CORS、国际化、Jackson 等。                |
+
+
+`**resources/static` / `templates**`:若需服务端薄模板或静态页(多数 SPA 仅 `static` 放空由 Nginx 托管前端构建产物)。
+
+### 2.7 `finrep-worker` 包路径 `.../worker/`
+
+
+| 包               | 作用                                      |
+| --------------- | --------------------------------------- |
+| `**consumer**`  | MQ 监听入口:收到消息后调用 `application` 层推进或调用算法。 |
+| `**handler**`   | 按消息类型拆分的处理逻辑(大纲生成、全文生成批次等)。             |
+| `**scheduler**` | 可选 `@Scheduled`:重试、超时扫描、清理临时数据。         |
+| `**config**`    | Worker 进程专属配置(线程池、并发度等)。                |
+
+
+---
+
+## 三、`algo/`(Python 算法 / Agent 服务)
+
+**FastAPI(建议)** 对内提供 **OpenAI 兼容** 大模型调用与 **报告专用技能**:一级/二级大纲、段落生成、RAG 检索等。由 `**backend`** 内网调用;**不对公网暴露**(或与网关结合白名单)。
+
+
+| 子目录                                             | 作用                                                                             |
+| ----------------------------------------------- | ------------------------------------------------------------------------------ |
+| `**src/finrep_algo_agent/api/routers`**         | HTTP 路由:按技能或资源划分 `POST /v1/outline/l1` 等接口定义。                                  |
+| `**src/finrep_algo_agent/api/deps**`            | FastAPI `Depends`:鉴权、限流、公共请求体解析、从 Header 注入 trace/tenant。                      |
+| `**src/finrep_algo_agent/agent**`               | 多步编排入口:LangGraph 或状态图定义、Human-in-the-loop **占位**(实际卡点仍在 Java/库表时,此处仅接收已确认上下文)。 |
+| `**src/finrep_algo_agent/skills/outline_l1`**   | **一级大纲**技能:拼装 Prompt、调模型、解析/校验 JSON、返回结构化一级章节结果。                               |
+| `**src/finrep_algo_agent/skills/outline_l2`**   | **二级大纲 / 知识单元装配**技能:按章调用、章节编号结构、与需求文档 JSON 契约对齐。                               |
+| `**src/finrep_algo_agent/skills/section_gen`**  | **按知识单元生成正文**技能:模板变量注入、段落级调用与拼接顺序由 Java 调多次或一次批处理(按设计定)。                       |
+| `**src/finrep_algo_agent/skills/rag_retrieve`** | **仅检索**:返回引用片段供上游拼 Prompt;或由 `services` 组合进 `section_gen`。                     |
+| `**src/finrep_algo_agent/llm/client`**          | OpenAI 兼容 HTTP 客户端封装(base_url、api_key、超时)。                                     |
+| `**src/finrep_algo_agent/llm/router**`          | **多模型路由**:按请求元数据选择模型别名、主备降级。                                                   |
+| `**src/finrep_algo_agent/llm/providers`**       | 各厂商/集群差异适配(若路由不足以覆盖)。                                                          |
+| `**src/finrep_algo_agent/rag/ingestion**`       | 文档解析、分块、写入向量库(离线或准实时任务)。                                                       |
+| `**src/finrep_algo_agent/rag/retrieval**`       | 查询改写、相似度检索、重排序(若需要)。                                                           |
+| `**src/finrep_algo_agent/rag/vectorstore**`     | 向量库访问封装(pgvector、Milvus 等)。                                                    |
+| `**src/finrep_algo_agent/prompts/templates**`   | Jinja2 / 文本模板文件;与需求文档章节结构对齐的版本管理。                                              |
+| `**src/finrep_algo_agent/prompts/builders**`    | 从领域 DTO 生成 messages 列表的构建器代码。                                                  |
+| `**src/finrep_algo_agent/schemas**`             | Pydantic 模型:请求/响应、大纲 JSON、与 Java DTO 命名对齐说明。                                   |
+| `**src/finrep_algo_agent/services**`            | 组合技能与仓储的高层服务;供 `routers` 薄层调用。                                                 |
+| `**src/finrep_algo_agent/core**`                | 领域无关内核:常量、共用异常、运行模式枚举等。                                                        |
+| `**src/finrep_algo_agent/config**`              | `pydantic-settings` / 环境变量:模型 URL、密钥(来自密钥管理)、功能开关。                             |
+| `**src/finrep_algo_agent/observability**`       | 日志结构化、Metrics、与 OpenTelemetry 对接。                                              |
+| `**tests/unit**`                                | 单测:解析、Prompt 构建、mock LLM。                                                      |
+| `**tests/integration**`                         | 集成测:需真实或 testcontainers 模型/向量库时启用。                                             |
+| `**scripts/**`                                  | 一次性脚本:索引重建、批量评测、Prompt 回归等。                                                    |
+| `**deploy/**`                                   | Dockerfile、k8s YAML、行内发布说明。                                                    |
+| `**docs/**`                                     | 算法侧设计:接口契约、与 Java 对齐字段、模型版本记录。                                                 |
+
+
+---
+
+## 四、三端协作关系(回顾)
+
+```text
+浏览器 → frontend → backend(REST + SSE)
+                    → backend.integration.pythonagent → algo(REST)
+```
+
+任务与人工确认的 **权威状态** 在 **backend 数据库**;`algo` 尽量 **无状态或可重建**,输出结构化结果供 Java 持久化。
+

+ 23 - 0
algo/.env

@@ -0,0 +1,23 @@
+# OpenAI 兼容接口(推荐:阿里云百炼 / 通义千问)
+FINREP_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+FINREP_LLM_API_KEY=sk-a963bd7d2a994f76a49d89f3efd1dcd5
+FINREP_LLM_MODEL=qwen-turbo
+FINREP_LLM_TIMEOUT_SECONDS=120
+
+# Embedding 模型(OpenAI 兼容 embeddings)
+FINREP_EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+FINREP_EMBEDDING_API_KEY=
+FINREP_EMBEDDING_MODEL=text-embedding-v3
+FINREP_EMBEDDING_TIMEOUT_SECONDS=60
+
+# OCR 解析模型(OpenAI 兼容多模态)
+FINREP_OCR_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+FINREP_OCR_API_KEY=
+FINREP_OCR_MODEL=qwen-vl-ocr
+FINREP_OCR_TIMEOUT_SECONDS=90
+
+# true:L1/L2/Section 不调模型,返回占位 JSON(联调 Java 时可先开)
+FINREP_STUB_SKILLS=false
+
+# 可选:服务间校验(占位,后续与 Java 对齐)
+FINREP_SERVICE_TOKEN=

+ 63 - 0
algo/README.md

@@ -0,0 +1,63 @@
+# finrep-algo-agent
+
+Python 算法服务骨架:健康检查、`/v1/outline/l1|l2`、`/v1/section` 占位实现,便于与 Java `pythonagent` 联调。
+
+## 本地运行
+
+```bash
+cd algo
+python -m venv .venv
+.\.venv\Scripts\activate
+pip install -e ".[dev]"
+uvicorn finrep_algo_agent.main:app --reload --host 0.0.0.0 --port 8002 --app-dir src
+```
+
+## 环境变量
+
+复制 `env.example` 为 `.env`(可选)。主要项:
+
+- `FINREP_LLM_BASE_URL`:OpenAI 兼容网关地址,如 `https://api.openai.com/v1`
+- `FINREP_LLM_API_KEY`:密钥
+- `FINREP_LLM_MODEL`:模型名
+- `FINREP_STUB_SKILLS=true`:为 `true` 时 L1/L2/Section 返回固定占位数据,不调模型
+
+推荐使用阿里云百炼 OpenAI 兼容模式(北京):
+
+- Base URL:`https://dashscope.aliyuncs.com/compatible-mode/v1`
+- 文本生成模型(示例):`qwen-plus`
+- 向量模型(示例):`text-embedding-v3`
+- OCR 解析模型(示例):`qwen-vl-ocr`
+
+本项目已支持三类模型独立配置(均为 OpenAI 兼容 HTTP):
+
+- 文本生成:`FINREP_LLM_*`
+- 向量 Embedding:`FINREP_EMBEDDING_*`
+- OCR 解析:`FINREP_OCR_*`
+
+如果三类模型共用同一个 API Key,可只填 `FINREP_LLM_API_KEY`;代码会在 Embedding/OCR 侧自动回退复用。
+
+## 如何获取阿里云百炼 API Key
+
+1. 进入阿里云百炼控制台(Model Studio)。
+2. 开通并进入对应地域(建议与你部署环境一致)。
+3. 在「API-KEY」或「密钥管理」页面创建 Key。
+4. 复制 Key 填入 `.env` 的 `FINREP_LLM_API_KEY`(或分别填到 `FINREP_EMBEDDING_API_KEY`、`FINREP_OCR_API_KEY`)。
+5. 重启服务使新环境变量生效。
+
+参考文档:
+
+- [OpenAI 兼容模式调用千问](https://www.alibabacloud.com/help/zh/model-studio/compatibility-of-openai-with-dashscope)
+- [OpenAI 兼容方式调用千问视觉模型](https://www.alibabacloud.com/help/zh/model-studio/qwen-vl-compatible-with-openai)
+
+## 联调
+
+- `GET http://localhost:8002/health`
+- `POST http://localhost:8002/v1/outline/l1`(JSON 见 `schemas`)
+
+## 与《MVP 需求文档》对齐的交互字段
+
+- **一级大纲 `POST /v1/outline/l1`**:请求体与需求文档「报告背景信息」「一级章节候选清单」一致:`report_type`、`agreement_amount`、`enterprise_type`、`group_business_segments`、`industry_type`、`has_independent_report`、`independent_report_types`、`candidate_financing_tools`、`recommended_financing_tools`、`other_requirements`、`chapter_candidates`(每项可含除 `chapter_id`/`chapter_name` 外的扩展字段,如重要性、适用条件,将原样写入清单文本供模型使用)。
+- **二级大纲 `POST /v1/outline/l2`**:除 `chapter_name`、`chapter_no`、`chapter_paragraph_count_enum`、`leaf_chapter_candidates` 外,需传入与需求文档「已确定的上游约束」「整体写作逻辑说明」对齐的 **`chapter_reason`**(一级该章 `reason`)、**`overall_logic`**(一级 `overall_logic`)。**报告背景**推荐整块传入 **`l1_task_snapshot`**(与 L1 请求同结构的 `OutlineL1Request`);若不传,则使用本请求上的扁平字段(`report_type`、`agreement_amount` 等)拼背景。
+- **段落生成 `POST /v1/section`**:`template_type` 取值 `info` / `analysis` / `metric` / `judgment` 对应需求文档四类模板;`overall_logic`、`chapter_logic`、`paragraph_position`、`task_input`、`data_package`、`paragraph_logic` 对应模板中「橙/蓝」占位;`example`、`notes` 对应「示例」「其他注意事项」。
+
+提示词正文位于 `src/finrep_algo_agent/prompts/templates/*.j2`,与需求文档摘录保持一致,后续仅以改模板版本迭代。

+ 16 - 0
algo/deploy/Dockerfile

@@ -0,0 +1,16 @@
+# 构建:在 algo 目录执行
+#   docker build -f deploy/Dockerfile .
+
+FROM python:3.12-slim
+
+WORKDIR /app
+ENV PYTHONUNBUFFERED=1 \
+    PYTHONDONTWRITEBYTECODE=1 \
+    PIP_NO_CACHE_DIR=1
+
+COPY pyproject.toml README.md ./
+COPY src ./src
+RUN pip install .
+
+EXPOSE 8002
+CMD ["uvicorn", "finrep_algo_agent.main:app", "--host", "0.0.0.0", "--port", "8002", "--app-dir", "src"]

+ 29 - 0
algo/env.example

@@ -0,0 +1,29 @@
+# OpenAI 兼容接口(推荐:阿里云百炼 / 通义千问)
+FINREP_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+FINREP_LLM_API_KEY=sk-a963bd7d2a994f76a49d89f3efd1dcd5
+FINREP_LLM_MODEL=Qwen3-32B
+FINREP_LLM_TIMEOUT_SECONDS=120
+
+# Embedding 模型(OpenAI 兼容 embeddings)
+FINREP_EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+FINREP_EMBEDDING_API_KEY=
+FINREP_EMBEDDING_MODEL=text-embedding-v3
+FINREP_EMBEDDING_TIMEOUT_SECONDS=60
+
+# OCR 解析模型(OpenAI 兼容多模态)
+FINREP_OCR_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+FINREP_OCR_API_KEY=
+FINREP_OCR_MODEL=qwen-vl-ocr
+FINREP_OCR_TIMEOUT_SECONDS=90
+
+# true:L1/L2/Section 不调模型,返回占位 JSON(联调 Java 时可先开)
+FINREP_STUB_SKILLS=true
+
+# RAG 分块/检索(可选,缺省见代码内默认值)
+# FINREP_RAG_CHUNK_SIZE=800
+# FINREP_RAG_CHUNK_OVERLAP=120
+# FINREP_RAG_DEFAULT_TOP_K=5
+# FINREP_RAG_EMBEDDING_BATCH_SIZE=16
+
+# 可选:服务间校验(占位,后续与 Java 对齐)
+FINREP_SERVICE_TOKEN=

+ 33 - 0
algo/pyproject.toml

@@ -0,0 +1,33 @@
+[build-system]
+requires = ["setuptools>=61", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "finrep-algo-agent"
+version = "0.1.0"
+description = "财顾报告 Agent — Python 算法服务(FastAPI)"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+    "fastapi>=0.110",
+    "uvicorn[standard]>=0.27",
+    "pydantic>=2",
+    "pydantic-settings>=2",
+    "httpx>=0.27",
+    "jinja2>=3.1",
+    "python-multipart>=0.0.9",
+    "pypdf>=4.0",
+]
+
+[project.optional-dependencies]
+dev = ["pytest>=8", "pytest-asyncio>=0.24"]
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.setuptools.package-data]
+finrep_algo_agent = ["prompts/templates/*.j2"]
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]

+ 81 - 0
algo/src/finrep_algo_agent.egg-info/PKG-INFO

@@ -0,0 +1,81 @@
+Metadata-Version: 2.4
+Name: finrep-algo-agent
+Version: 0.1.0
+Summary: 财顾报告 Agent — Python 算法服务(FastAPI)
+Requires-Python: >=3.11
+Description-Content-Type: text/markdown
+Requires-Dist: fastapi>=0.110
+Requires-Dist: uvicorn[standard]>=0.27
+Requires-Dist: pydantic>=2
+Requires-Dist: pydantic-settings>=2
+Requires-Dist: httpx>=0.27
+Requires-Dist: jinja2>=3.1
+Requires-Dist: python-multipart>=0.0.9
+Requires-Dist: pypdf>=4.0
+Provides-Extra: dev
+Requires-Dist: pytest>=8; extra == "dev"
+Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
+
+# finrep-algo-agent
+
+Python 算法服务骨架:健康检查、`/v1/outline/l1|l2`、`/v1/section` 占位实现,便于与 Java `pythonagent` 联调。
+
+## 本地运行
+
+```bash
+cd algo
+python -m venv .venv
+.\.venv\Scripts\activate
+pip install -e ".[dev]"
+uvicorn finrep_algo_agent.main:app --reload --host 0.0.0.0 --port 8002 --app-dir src
+```
+
+## 环境变量
+
+复制 `env.example` 为 `.env`(可选)。主要项:
+
+- `FINREP_LLM_BASE_URL`:OpenAI 兼容网关地址,如 `https://api.openai.com/v1`
+- `FINREP_LLM_API_KEY`:密钥
+- `FINREP_LLM_MODEL`:模型名
+- `FINREP_STUB_SKILLS=true`:为 `true` 时 L1/L2/Section 返回固定占位数据,不调模型
+
+推荐使用阿里云百炼 OpenAI 兼容模式(北京):
+
+- Base URL:`https://dashscope.aliyuncs.com/compatible-mode/v1`
+- 文本生成模型(示例):`qwen-plus`
+- 向量模型(示例):`text-embedding-v3`
+- OCR 解析模型(示例):`qwen-vl-ocr`
+
+本项目已支持三类模型独立配置(均为 OpenAI 兼容 HTTP):
+
+- 文本生成:`FINREP_LLM_*`
+- 向量 Embedding:`FINREP_EMBEDDING_*`
+- OCR 解析:`FINREP_OCR_*`
+
+如果三类模型共用同一个 API Key,可只填 `FINREP_LLM_API_KEY`;代码会在 Embedding/OCR 侧自动回退复用。
+
+## 如何获取阿里云百炼 API Key
+
+1. 进入阿里云百炼控制台(Model Studio)。
+2. 开通并进入对应地域(建议与你部署环境一致)。
+3. 在「API-KEY」或「密钥管理」页面创建 Key。
+4. 复制 Key 填入 `.env` 的 `FINREP_LLM_API_KEY`(或分别填到 `FINREP_EMBEDDING_API_KEY`、`FINREP_OCR_API_KEY`)。
+5. 重启服务使新环境变量生效。
+
+参考文档:
+
+- [OpenAI 兼容模式调用千问](https://www.alibabacloud.com/help/zh/model-studio/compatibility-of-openai-with-dashscope)
+- [OpenAI 兼容方式调用千问视觉模型](https://www.alibabacloud.com/help/zh/model-studio/qwen-vl-compatible-with-openai)
+
+## 联调
+
+- `GET http://localhost:8002/health`
+- `POST http://localhost:8002/v1/outline/l1`(JSON 见 `schemas`)
+
+## 与《MVP 需求文档》对齐的交互字段
+
+- **一级大纲 `POST /v1/outline/l1`**:请求体与需求文档「报告背景信息」「一级章节候选清单」一致:`report_type`、`agreement_amount`、`enterprise_type`、`group_business_segments`、`industry_type`、`has_independent_report`、`independent_report_types`、`candidate_financing_tools`、`recommended_financing_tools`、`other_requirements`、`chapter_candidates`(每项可含除 `chapter_id`/`chapter_name` 外的扩展字段,如重要性、适用条件,将原样写入清单文本供模型使用)。
+- **二级大纲 `POST /v1/outline/l2`**:除 `chapter_name`、`chapter_no`、`chapter_paragraph_count_enum`、`leaf_chapter_candidates` 外,需传入与需求文档「已确定的上游约束」「整体写作逻辑说明」对齐的 **`chapter_reason`**(一级该章 `reason`)、**`overall_logic`**(一级 `overall_logic`)。**报告背景**推荐整块传入 **`l1_task_snapshot`**(与 L1 请求同结构的 `OutlineL1Request`);若不传,则使用本请求上的扁平字段(`report_type`、`agreement_amount` 等)拼背景。
+- **段落生成 `POST /v1/section`**:`template_type` 取值 `info` / `analysis` / `metric` / `judgment` 对应需求文档四类模板;`overall_logic`、`chapter_logic`、`paragraph_position`、`task_input`、`data_package`、`paragraph_logic` 对应模板中「橙/蓝」占位;`example`、`notes` 对应「示例」「其他注意事项」。
+
+提示词正文位于 `src/finrep_algo_agent/prompts/templates/*.j2`,与需求文档摘录保持一致,后续仅以改模板版本迭代。

+ 56 - 0
algo/src/finrep_algo_agent.egg-info/SOURCES.txt

@@ -0,0 +1,56 @@
+README.md
+pyproject.toml
+src/finrep_algo_agent/__init__.py
+src/finrep_algo_agent/main.py
+src/finrep_algo_agent.egg-info/PKG-INFO
+src/finrep_algo_agent.egg-info/SOURCES.txt
+src/finrep_algo_agent.egg-info/dependency_links.txt
+src/finrep_algo_agent.egg-info/requires.txt
+src/finrep_algo_agent.egg-info/top_level.txt
+src/finrep_algo_agent/api/__init__.py
+src/finrep_algo_agent/api/deps.py
+src/finrep_algo_agent/api/routers/__init__.py
+src/finrep_algo_agent/api/routers/debug.py
+src/finrep_algo_agent/api/routers/health.py
+src/finrep_algo_agent/api/routers/outline.py
+src/finrep_algo_agent/api/routers/rag.py
+src/finrep_algo_agent/api/routers/section.py
+src/finrep_algo_agent/config/__init__.py
+src/finrep_algo_agent/config/settings.py
+src/finrep_algo_agent/llm/__init__.py
+src/finrep_algo_agent/llm/client/client.py
+src/finrep_algo_agent/prompts/__init__.py
+src/finrep_algo_agent/prompts/builders.py
+src/finrep_algo_agent/prompts/templates/outline_l1.j2
+src/finrep_algo_agent/prompts/templates/outline_l2.j2
+src/finrep_algo_agent/prompts/templates/section_analysis.j2
+src/finrep_algo_agent/prompts/templates/section_info.j2
+src/finrep_algo_agent/prompts/templates/section_judgment.j2
+src/finrep_algo_agent/prompts/templates/section_metric.j2
+src/finrep_algo_agent/rag/__init__.py
+src/finrep_algo_agent/rag/ingestion/__init__.py
+src/finrep_algo_agent/rag/ingestion/chunking.py
+src/finrep_algo_agent/rag/ingestion/file_extract.py
+src/finrep_algo_agent/rag/retrieval/__init__.py
+src/finrep_algo_agent/rag/retrieval/formatting.py
+src/finrep_algo_agent/rag/retrieval/similarity.py
+src/finrep_algo_agent/rag/vectorstore/__init__.py
+src/finrep_algo_agent/rag/vectorstore/store.py
+src/finrep_algo_agent/schemas/__init__.py
+src/finrep_algo_agent/schemas/contracts.py
+src/finrep_algo_agent/schemas/outline.py
+src/finrep_algo_agent/schemas/rag.py
+src/finrep_algo_agent/schemas/section.py
+src/finrep_algo_agent/skills/__init__.py
+src/finrep_algo_agent/skills/outline_l1/__init__.py
+src/finrep_algo_agent/skills/outline_l1/outline_l1.py
+src/finrep_algo_agent/skills/outline_l2/__init__.py
+src/finrep_algo_agent/skills/outline_l2/outline_l2.py
+src/finrep_algo_agent/skills/rag_retrieve/__init__.py
+src/finrep_algo_agent/skills/rag_retrieve/rag_service.py
+src/finrep_algo_agent/skills/section_gen/__init__.py
+src/finrep_algo_agent/skills/section_gen/section_gen.py
+tests/test_health.py
+tests/test_prompts.py
+tests/test_rag.py
+tests/test_section_rag.py

+ 1 - 0
algo/src/finrep_algo_agent.egg-info/dependency_links.txt

@@ -0,0 +1 @@
+

+ 12 - 0
algo/src/finrep_algo_agent.egg-info/requires.txt

@@ -0,0 +1,12 @@
+fastapi>=0.110
+uvicorn[standard]>=0.27
+pydantic>=2
+pydantic-settings>=2
+httpx>=0.27
+jinja2>=3.1
+python-multipart>=0.0.9
+pypdf>=4.0
+
+[dev]
+pytest>=8
+pytest-asyncio>=0.24

+ 1 - 0
algo/src/finrep_algo_agent.egg-info/top_level.txt

@@ -0,0 +1 @@
+finrep_algo_agent

+ 3 - 0
algo/src/finrep_algo_agent/__init__.py

@@ -0,0 +1,3 @@
+"""财顾报告 Agent — Python 算法服务。"""
+
+__version__ = "0.1.0"

二進制
algo/src/finrep_algo_agent/__pycache__/__init__.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/__pycache__/main.cpython-312.pyc


+ 1 - 0
algo/src/finrep_algo_agent/api/__init__.py

@@ -0,0 +1 @@
+"""HTTP 层:路由与依赖。"""

二進制
algo/src/finrep_algo_agent/api/__pycache__/__init__.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/api/__pycache__/deps.cpython-312.pyc


+ 35 - 0
algo/src/finrep_algo_agent/api/deps.py

@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from typing import Annotated
+
+from fastapi import Depends
+
+from finrep_algo_agent.config import Settings, get_settings
+from finrep_algo_agent.llm import LlmClient
+from finrep_algo_agent.rag.vectorstore import InMemoryRagStore
+from finrep_algo_agent.skills.rag_retrieve import RagService
+
+_rag_store: InMemoryRagStore | None = None
+
+
+def get_llm(settings: Annotated[Settings, Depends(get_settings)]) -> LlmClient:
+    return LlmClient(settings)
+
+
+def get_rag_store() -> InMemoryRagStore:
+    global _rag_store
+    if _rag_store is None:
+        _rag_store = InMemoryRagStore()
+    return _rag_store
+
+
+def get_rag_service(
+    settings: Annotated[Settings, Depends(get_settings)],
+    llm: Annotated[LlmClient, Depends(get_llm)],
+) -> RagService:
+    return RagService(settings=settings, llm=llm, store=get_rag_store())
+
+
+SettingsDep = Annotated[Settings, Depends(get_settings)]
+LlmDep = Annotated[LlmClient, Depends(get_llm)]
+RagServiceDep = Annotated[RagService, Depends(get_rag_service)]

+ 7 - 0
algo/src/finrep_algo_agent/api/routers/__init__.py

@@ -0,0 +1,7 @@
+from finrep_algo_agent.api.routers.debug import router as debug_router
+from finrep_algo_agent.api.routers.health import router as health_router
+from finrep_algo_agent.api.routers.outline import router as outline_router
+from finrep_algo_agent.api.routers.rag import router as rag_router
+from finrep_algo_agent.api.routers.section import router as section_router
+
+__all__ = ["health_router", "outline_router", "rag_router", "section_router", "debug_router"]

二進制
algo/src/finrep_algo_agent/api/routers/__pycache__/__init__.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/api/routers/__pycache__/debug.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/api/routers/__pycache__/health.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/api/routers/__pycache__/outline.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/api/routers/__pycache__/rag.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/api/routers/__pycache__/section.cpython-312.pyc


+ 68 - 0
algo/src/finrep_algo_agent/api/routers/debug.py

@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, HTTPException
+
+from finrep_algo_agent.api.deps import LlmDep, SettingsDep
+
+router = APIRouter(tags=["debug"])
+
+
+@router.get("/debug/runtime")
+async def debug_runtime(settings: SettingsDep) -> dict[str, object]:
+    return {
+        "stub_skills": settings.stub_skills,
+        "llm_model": settings.llm_model,
+        "embedding_model": settings.embedding_model,
+        "ocr_model": settings.ocr_model,
+        "rag_defaults": {
+            "chunk_size": settings.rag_chunk_size,
+            "chunk_overlap": settings.rag_chunk_overlap,
+            "top_k": settings.rag_default_top_k,
+            "embedding_batch_size": settings.rag_embedding_batch_size,
+        },
+    }
+
+
+@router.get("/debug/llm")
+async def debug_llm(settings: SettingsDep, llm: LlmDep) -> dict[str, str]:
+    if not settings.llm_api_key:
+        raise HTTPException(status_code=400, detail="FINREP_LLM_API_KEY 未配置")
+    text = await llm.chat_completion(
+        [{"role": "user", "content": "请用一句话回答:你是哪个模型?"}],
+        temperature=0,
+    )
+    return {
+        "model": settings.llm_model,
+        "base_url": settings.llm_base_url,
+        "text_sample": text[:200],
+    }
+
+
+@router.get("/debug/embedding")
+async def debug_embedding(settings: SettingsDep, llm: LlmDep) -> dict[str, object]:
+    if not (settings.embedding_api_key or settings.llm_api_key):
+        raise HTTPException(
+            status_code=400, detail="FINREP_EMBEDDING_API_KEY 与 FINREP_LLM_API_KEY 均未配置"
+        )
+    vec = await llm.embedding("测试向量 embedding")
+    return {
+        "model": settings.embedding_model,
+        "base_url": settings.embedding_base_url,
+        "dim": len(vec),
+        "head": vec[:8],
+    }
+
+
+@router.get("/debug/ocr")
+async def debug_ocr(settings: SettingsDep, llm: LlmDep, image_url: str) -> dict[str, str]:
+    if not (settings.ocr_api_key or settings.llm_api_key):
+        raise HTTPException(
+            status_code=400, detail="FINREP_OCR_API_KEY 与 FINREP_LLM_API_KEY 均未配置"
+        )
+    text = await llm.ocr_parse(image_url=image_url)
+    return {
+        "model": settings.ocr_model,
+        "base_url": settings.ocr_base_url,
+        "text_sample": text[:400],
+    }
+

+ 12 - 0
algo/src/finrep_algo_agent/api/routers/health.py

@@ -0,0 +1,12 @@
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+from finrep_algo_agent import __version__
+
+router = APIRouter(tags=["health"])
+
+
+@router.get("/health")
+def health() -> dict[str, str]:
+    return {"status": "ok", "service": "finrep-algo-agent", "version": __version__}

+ 25 - 0
algo/src/finrep_algo_agent/api/routers/outline.py

@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, HTTPException
+
+from finrep_algo_agent.api.deps import LlmDep, SettingsDep
+from finrep_algo_agent.schemas.outline import OutlineL1Request, OutlineL1Response, OutlineL2Request, OutlineL2Response
+from finrep_algo_agent.skills import run_outline_l1, run_outline_l2
+
+router = APIRouter()
+
+
+@router.post("/l1", response_model=OutlineL1Response)
+async def outline_l1(body: OutlineL1Request, settings: SettingsDep, llm: LlmDep) -> OutlineL1Response:
+    try:
+        return await run_outline_l1(body, settings=settings, llm=llm)
+    except ValueError as e:
+        raise HTTPException(status_code=422, detail=str(e)) from e
+
+
+@router.post("/l2", response_model=OutlineL2Response)
+async def outline_l2(body: OutlineL2Request, settings: SettingsDep, llm: LlmDep) -> OutlineL2Response:
+    try:
+        return await run_outline_l2(body, settings=settings, llm=llm)
+    except ValueError as e:
+        raise HTTPException(status_code=422, detail=str(e)) from e

+ 130 - 0
algo/src/finrep_algo_agent/api/routers/rag.py

@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, File, Form, HTTPException, UploadFile
+
+from finrep_algo_agent.api.deps import RagServiceDep, SettingsDep
+from finrep_algo_agent.rag.ingestion import extract_text_from_upload
+from finrep_algo_agent.schemas.rag import (
+    RagDeleteResponse,
+    RagDocumentIn,
+    RagFileProcessResult,
+    RagIngestFilesResponse,
+    RagIngestRequest,
+    RagIngestResponse,
+    RagRetrieveRequest,
+    RagRetrieveResponse,
+)
+
+router = APIRouter()
+
+
+@router.post("/ingest-files", response_model=RagIngestFilesResponse)
+async def rag_ingest_files(
+    settings: SettingsDep,
+    rag: RagServiceDep,
+    task_id: str = Form(..., description="报告任务 ID,材料向量按 task 隔离"),
+    replace: bool = Form(True),
+    files: list[UploadFile] = File(..., description="业务上传材料,服务端解析文本后自动分块入库"),
+) -> RagIngestFilesResponse:
+    """文件直达 Python 时走本接口:解析 → 向量化 → 写入本任务 RAG 索引(无需 Java 先抽正文)。"""
+    if not (settings.embedding_api_key or settings.llm_api_key):
+        raise HTTPException(
+            status_code=400,
+            detail="RAG 入库需配置 FINREP_EMBEDDING_API_KEY 或 FINREP_LLM_API_KEY",
+        )
+    if not files:
+        raise HTTPException(status_code=422, detail="files 不能为空")
+
+    file_results: list[RagFileProcessResult] = []
+    documents: list[RagDocumentIn] = []
+    for uf in files:
+        raw = await uf.read()
+        ex = extract_text_from_upload(filename=uf.filename or "upload.bin", data=raw)
+        fr = RagFileProcessResult(
+            filename=ex.source_label,
+            doc_id=ex.doc_id,
+            characters=len(ex.text),
+            skipped=not bool(ex.text),
+            warning=ex.warning,
+        )
+        file_results.append(fr)
+        if ex.text:
+            documents.append(
+                RagDocumentIn(
+                    doc_id=ex.doc_id,
+                    title="",
+                    text=ex.text,
+                    source_label=ex.source_label,
+                )
+            )
+
+    if not documents:
+        raise HTTPException(
+            status_code=422,
+            detail="所有文件均未解析出有效文本,未写入索引",
+        )
+
+    try:
+        ing = await rag.ingest(task_id, documents, replace=replace)
+    except ValueError as e:
+        raise HTTPException(status_code=422, detail=str(e)) from e
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"向量化服务异常: {e}") from e
+
+    return RagIngestFilesResponse(
+        task_id=ing.task_id,
+        document_count=ing.document_count,
+        chunk_count=ing.chunk_count,
+        files=file_results,
+    )
+
+
+@router.post("/ingest", response_model=RagIngestResponse)
+async def rag_ingest(
+    body: RagIngestRequest,
+    settings: SettingsDep,
+    rag: RagServiceDep,
+) -> RagIngestResponse:
+    if not (settings.embedding_api_key or settings.llm_api_key):
+        raise HTTPException(
+            status_code=400,
+            detail="RAG 入库需配置 FINREP_EMBEDDING_API_KEY 或 FINREP_LLM_API_KEY",
+        )
+    try:
+        return await rag.ingest(
+            body.task_id,
+            body.documents,
+            replace=body.replace,
+        )
+    except ValueError as e:
+        raise HTTPException(status_code=422, detail=str(e)) from e
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"向量化服务异常: {e}") from e
+
+
+@router.post("/retrieve", response_model=RagRetrieveResponse)
+async def rag_retrieve(
+    body: RagRetrieveRequest,
+    settings: SettingsDep,
+    rag: RagServiceDep,
+) -> RagRetrieveResponse:
+    if not (settings.embedding_api_key or settings.llm_api_key):
+        raise HTTPException(
+            status_code=400,
+            detail="RAG 检索需配置 FINREP_EMBEDDING_API_KEY 或 FINREP_LLM_API_KEY",
+        )
+    try:
+        return await rag.retrieve(
+            body.task_id,
+            body.query,
+            top_k=body.top_k,
+            min_score=body.min_score,
+        )
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"检索服务异常: {e}") from e
+
+
+@router.delete("/{task_id}", response_model=RagDeleteResponse)
+async def rag_delete_index(task_id: str, rag: RagServiceDep) -> RagDeleteResponse:
+    deleted = rag.delete_index(task_id)
+    return RagDeleteResponse(task_id=task_id, deleted=deleted)

+ 22 - 0
algo/src/finrep_algo_agent/api/routers/section.py

@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, HTTPException
+
+from finrep_algo_agent.api.deps import LlmDep, RagServiceDep, SettingsDep
+from finrep_algo_agent.schemas.section import SectionRequest, SectionResponse
+from finrep_algo_agent.skills import run_section
+
+router = APIRouter()
+
+
+@router.post("/section", response_model=SectionResponse)
+async def section(
+    body: SectionRequest,
+    settings: SettingsDep,
+    llm: LlmDep,
+    rag: RagServiceDep,
+) -> SectionResponse:
+    try:
+        return await run_section(body, settings=settings, llm=llm, rag=rag)
+    except ValueError as e:
+        raise HTTPException(status_code=422, detail=str(e)) from e

+ 3 - 0
algo/src/finrep_algo_agent/config/__init__.py

@@ -0,0 +1,3 @@
+from finrep_algo_agent.config.settings import Settings, get_settings
+
+__all__ = ["Settings", "get_settings"]

二進制
algo/src/finrep_algo_agent/config/__pycache__/__init__.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/config/__pycache__/settings.cpython-312.pyc


+ 49 - 0
algo/src/finrep_algo_agent/config/settings.py

@@ -0,0 +1,49 @@
+from functools import lru_cache
+
+from pydantic import Field
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+    model_config = SettingsConfigDict(
+        env_prefix="FINREP_",
+        env_file=".env",
+        env_file_encoding="utf-8",
+        extra="ignore",
+    )
+
+    # 文本生成模型(默认:阿里云百炼 OpenAI 兼容模式)
+    llm_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
+    llm_api_key: str = ""
+    llm_model: str = "qwen-plus"
+    llm_timeout_seconds: float = 120.0
+
+    # 向量模型(OpenAI 兼容 embeddings)
+    embedding_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
+    embedding_api_key: str = ""
+    embedding_model: str = "text-embedding-v3"
+    embedding_timeout_seconds: float = 60.0
+
+    # RAG:分块与检索默认参数(对齐需求「上传材料召回」与工程建议 metadata/切分)
+    rag_chunk_size: int = Field(default=800, description="单块大致字符数(中英混合按字符计)")
+    rag_chunk_overlap: int = Field(default=120, description="相邻块重叠字符数")
+    rag_default_top_k: int = Field(default=5, description="默认返回片段数")
+    rag_embedding_batch_size: int = Field(
+        default=16,
+        description="单次向量化请求的文本段数上限,过大可能触 API 限制",
+    )
+
+    # OCR 解析模型(OpenAI 兼容多模态)
+    ocr_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
+    ocr_api_key: str = ""
+    ocr_model: str = "qwen-vl-ocr"
+    ocr_timeout_seconds: float = 90.0
+    # True:技能返回占位 JSON,不请求 LLM(便于先联调 Java)
+    stub_skills: bool = Field(default=True)
+
+    service_token: str = ""
+
+
+@lru_cache
+def get_settings() -> Settings:
+    return Settings()

+ 3 - 0
algo/src/finrep_algo_agent/llm/__init__.py

@@ -0,0 +1,3 @@
+from finrep_algo_agent.llm.client.client import LlmClient
+
+__all__ = ["LlmClient"]

二進制
algo/src/finrep_algo_agent/llm/__pycache__/__init__.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/llm/__pycache__/client.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/llm/client/__pycache__/client.cpython-312.pyc


+ 163 - 0
algo/src/finrep_algo_agent/llm/client/client.py

@@ -0,0 +1,163 @@
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+import httpx
+
+from finrep_algo_agent.config import Settings
+
+logger = logging.getLogger(__name__)
+
+
+class LlmClient:
+    """OpenAI 兼容 `POST {base_url}/chat/completions` 的薄封装。"""
+
+    def __init__(self, settings: Settings) -> None:
+        self._settings = settings
+        self._chat_url = self._join_path(settings.llm_base_url, "chat/completions")
+        self._embedding_url = self._join_path(
+            settings.embedding_base_url, "embeddings"
+        )
+        self._ocr_chat_url = self._join_path(settings.ocr_base_url, "chat/completions")
+
+    @staticmethod
+    def _join_path(base_url: str, path: str) -> str:
+        base = base_url.rstrip("/")
+        if base.endswith("/v1"):
+            return f"{base}/{path}"
+        return f"{base}/v1/{path}"
+
+    @staticmethod
+    def _build_headers(api_key: str) -> dict[str, str]:
+        headers: dict[str, str] = {"Content-Type": "application/json"}
+        if api_key:
+            headers["Authorization"] = f"Bearer {api_key}"
+        return headers
+
+    async def chat_completion(
+        self,
+        messages: list[dict[str, str]],
+        *,
+        temperature: float = 0.3,
+        response_format: dict[str, Any] | None = None,
+    ) -> str:
+        payload: dict[str, Any] = {
+            "model": self._settings.llm_model,
+            "messages": messages,
+            "temperature": temperature,
+        }
+        if response_format is not None:
+            payload["response_format"] = response_format
+
+        timeout = httpx.Timeout(self._settings.llm_timeout_seconds)
+        async with httpx.AsyncClient(timeout=timeout) as client:
+            resp = await client.post(
+                self._chat_url,
+                json=payload,
+                headers=self._build_headers(self._settings.llm_api_key),
+            )
+            resp.raise_for_status()
+            data = resp.json()
+
+        choices = data.get("choices") or []
+        if not choices:
+            raise ValueError("LLM response missing choices")
+        message = choices[0].get("message") or {}
+        content = message.get("content")
+        if not isinstance(content, str):
+            raise ValueError("LLM response missing message.content")
+        return content
+
+    async def embedding(self, text: str) -> list[float]:
+        vecs = await self.embeddings([text])
+        return vecs[0]
+
+    async def embeddings(self, texts: list[str]) -> list[list[float]]:
+        if not texts:
+            return []
+        payload: dict[str, Any] = {
+            "model": self._settings.embedding_model,
+            "input": texts,
+        }
+        timeout = httpx.Timeout(self._settings.embedding_timeout_seconds)
+        api_key = self._settings.embedding_api_key or self._settings.llm_api_key
+        async with httpx.AsyncClient(timeout=timeout) as client:
+            resp = await client.post(
+                self._embedding_url,
+                json=payload,
+                headers=self._build_headers(api_key),
+            )
+            resp.raise_for_status()
+            data = resp.json()
+        rows = data.get("data") or []
+        if len(rows) != len(texts):
+            raise ValueError(
+                f"Embedding count mismatch: got {len(rows)}, expected {len(texts)}"
+            )
+        if rows and all("index" in r for r in rows):
+            ordered = sorted(rows, key=lambda r: int(r["index"]))
+        else:
+            ordered = rows
+        out: list[list[float]] = []
+        for row in ordered:
+            vector = row.get("embedding")
+            if not isinstance(vector, list):
+                raise ValueError("Embedding response missing embedding vector")
+            out.append(vector)
+        return out
+
+    async def ocr_parse(self, image_url: str, prompt: str = "请提取图片中的文字。") -> str:
+        payload = {
+            "model": self._settings.ocr_model,
+            "messages": [
+                {
+                    "role": "user",
+                    "content": [
+                        {"type": "text", "text": prompt},
+                        {"type": "image_url", "image_url": {"url": image_url}},
+                    ],
+                }
+            ],
+            "temperature": 0,
+        }
+        timeout = httpx.Timeout(self._settings.ocr_timeout_seconds)
+        api_key = self._settings.ocr_api_key or self._settings.llm_api_key
+        async with httpx.AsyncClient(timeout=timeout) as client:
+            resp = await client.post(
+                self._ocr_chat_url,
+                json=payload,
+                headers=self._build_headers(api_key),
+            )
+            resp.raise_for_status()
+            data = resp.json()
+
+        choices = data.get("choices") or []
+        if not choices:
+            raise ValueError("OCR response missing choices")
+        message = choices[0].get("message") or {}
+        content = message.get("content")
+        if isinstance(content, str):
+            return content
+        if isinstance(content, list):
+            text_parts: list[str] = []
+            for part in content:
+                if isinstance(part, dict) and part.get("type") == "text":
+                    val = part.get("text")
+                    if isinstance(val, str):
+                        text_parts.append(val)
+            if text_parts:
+                return "\n".join(text_parts)
+        raise ValueError("OCR response missing text content")
+
+    async def ping(self) -> bool:
+        """发一条极短请求,用于探测连通性。"""
+        try:
+            await self.chat_completion(
+                [{"role": "user", "content": "ping"}],
+                temperature=0,
+            )
+            return True
+        except Exception as e:
+            logger.warning("LLM ping failed: %s", e)
+            return False

+ 44 - 0
algo/src/finrep_algo_agent/main.py

@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from contextlib import asynccontextmanager
+import logging
+
+from fastapi import FastAPI
+
+from finrep_algo_agent.api.routers import (
+    debug_router,
+    health_router,
+    outline_router,
+    rag_router,
+    section_router,
+)
+from finrep_algo_agent.config import get_settings
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)s %(name)s %(message)s",
+)
+
+@asynccontextmanager
+async def _lifespan(_: FastAPI):
+    settings = get_settings()
+    if settings.stub_skills:
+        logging.warning(
+            "FINREP_STUB_SKILLS=true,当前运行在演示占位模式:"
+            "outline/section 不会调用真实模型。"
+        )
+    yield
+
+
+app = FastAPI(
+    title="finrep-algo-agent",
+    version="0.1.0",
+    description="财顾报告 Agent — Python 算法服务",
+    lifespan=_lifespan,
+)
+
+app.include_router(health_router)
+app.include_router(debug_router)
+app.include_router(outline_router, prefix="/v1/outline", tags=["outline"])
+app.include_router(rag_router, prefix="/v1/rag", tags=["rag"])
+app.include_router(section_router, prefix="/v1", tags=["section"])

+ 11 - 0
algo/src/finrep_algo_agent/prompts/__init__.py

@@ -0,0 +1,11 @@
+from finrep_algo_agent.prompts.builders import (
+    build_outline_l1_user_prompt,
+    build_outline_l2_user_prompt,
+    build_section_user_prompt,
+)
+
+__all__ = [
+    "build_outline_l1_user_prompt",
+    "build_outline_l2_user_prompt",
+    "build_section_user_prompt",
+]

二進制
algo/src/finrep_algo_agent/prompts/__pycache__/__init__.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/prompts/__pycache__/builders.cpython-312.pyc


+ 174 - 0
algo/src/finrep_algo_agent/prompts/builders.py

@@ -0,0 +1,174 @@
+from __future__ import annotations
+
+import json
+from decimal import Decimal
+from pathlib import Path
+
+from jinja2 import Environment, FileSystemLoader, select_autoescape
+
+from finrep_algo_agent.schemas.outline import OutlineL1Request, OutlineL2Request
+from finrep_algo_agent.schemas.section import SectionRequest
+
+_TPL_DIR = Path(__file__).resolve().parent / "templates"
+
+
+def _env() -> Environment:
+    return Environment(
+        loader=FileSystemLoader(_TPL_DIR),
+        autoescape=select_autoescape(disabled_extensions=("j2",)),
+        trim_blocks=True,
+        lstrip_blocks=True,
+    )
+
+
+def _fmt_list(items: list[str] | None) -> str:
+    if not items:
+        return "无"
+    return "、".join(str(x) for x in items)
+
+
+def _fmt_bool(v: bool | None) -> str:
+    if v is True:
+        return "是"
+    if v is False:
+        return "否"
+    return "未提供"
+
+
+def _fmt_decimal(v: Decimal | None) -> str:
+    if v is None:
+        return "未提供"
+    return str(v)
+
+
+def _format_kv_block(obj: dict[str, object], indent: str = "  ") -> str:
+    if not obj:
+        return f"{indent}(无扩展字段)"
+    lines: list[str] = []
+    for k in sorted(obj.keys(), key=lambda x: str(x)):
+        v = obj[k]
+        if isinstance(v, (dict, list)):
+            lines.append(f"{indent}{k}: {json.dumps(v, ensure_ascii=False)}")
+        else:
+            lines.append(f"{indent}{k}: {v}")
+    return "\n".join(lines)
+
+
+def format_chapter_candidates_block(req: OutlineL1Request) -> str:
+    """仅用于请求体显式传入 `chapter_candidates` 时的覆盖文本。"""
+    cands = req.chapter_candidates or []
+    if not cands:
+        return ""
+    parts: list[str] = []
+    for i, c in enumerate(cands, start=1):
+        row = c.model_dump(mode="python", exclude_none=True)
+        cid = row.pop("chapter_id", "")
+        cname = row.pop("chapter_name", "")
+        parts.append(f"【候选 {i}】")
+        parts.append(f"  chapter_id: {cid}")
+        parts.append(f"  chapter_name: {cname}")
+        parts.append("  附加属性(来自预置知识体系,原样供判断):")
+        parts.append(_format_kv_block(row) if row else "  (无扩展字段)")
+        parts.append("")
+    return "\n".join(parts).strip()
+
+
+def format_leaf_chapter_candidates_block(leaf: list[dict]) -> str:
+    """仅用于请求体显式传入 `leaf_chapter_candidates` 时的覆盖文本。"""
+    if not leaf:
+        return ""
+    parts: list[str] = []
+    for i, item in enumerate(leaf, start=1):
+        parts.append(f"【末级候选 {i}】")
+        parts.append(_format_kv_block(dict(item), indent="  "))
+        parts.append("")
+    parts.append("--- JSON(机器可读备份,与上一致)---")
+    parts.append(json.dumps(leaf, ensure_ascii=False, indent=2))
+    return "\n".join(parts).strip()
+
+
+def _task_background_dict(req: OutlineL1Request) -> dict[str, str]:
+    return {
+        "report_type": (req.report_type or "").strip(),
+        "agreement_amount": _fmt_decimal(req.agreement_amount),
+        "enterprise_type": req.enterprise_type or "未提供",
+        "group_business_segments": _fmt_list(req.group_business_segments),
+        "industry_type": req.industry_type or "未提供",
+        "has_independent_report": _fmt_bool(req.has_independent_report),
+        "independent_report_types": _fmt_list(req.independent_report_types),
+        "candidate_financing_tools": _fmt_list(req.candidate_financing_tools),
+        "recommended_financing_tools": _fmt_list(req.recommended_financing_tools),
+        "other_requirements": req.other_requirements or "无",
+    }
+
+
+def build_outline_l1_user_prompt(req: OutlineL1Request) -> str:
+    ctx = _task_background_dict(req)
+    ctx["chapter_candidates_override"] = format_chapter_candidates_block(req)
+    return _env().get_template("outline_l1.j2").render(**ctx)
+
+
+def _outline_l2_background(req: OutlineL2Request) -> OutlineL1Request:
+    if req.l1_task_snapshot is not None:
+        return req.l1_task_snapshot
+    return OutlineL1Request(
+        report_type=req.report_type or "未分类",
+        agreement_amount=req.agreement_amount,
+        enterprise_type=req.enterprise_type,
+        group_business_segments=list(req.group_business_segments),
+        industry_type=req.industry_type,
+        has_independent_report=req.has_independent_report,
+        independent_report_types=list(req.independent_report_types),
+        candidate_financing_tools=list(req.candidate_financing_tools),
+        recommended_financing_tools=list(req.recommended_financing_tools),
+        other_requirements=req.other_requirements,
+        chapter_candidates=[],
+    )
+
+
+def build_outline_l2_user_prompt(req: OutlineL2Request) -> str:
+    ctx = _task_background_dict(_outline_l2_background(req))
+    ctx.update(
+        {
+            "chapter_name": req.chapter_name,
+            "chapter_no": req.chapter_no,
+            "l1_chapter_id": (req.l1_chapter_id or "").strip(),
+            "chapter_presentation_enum": (req.chapter_presentation_enum or "未提供").strip(),
+            "chapter_paragraph_count_enum": req.chapter_paragraph_count_enum or "未提供",
+            "chapter_reason": req.chapter_reason or "无",
+            "overall_logic": req.overall_logic or "无",
+            "leaf_chapter_candidates_override": format_leaf_chapter_candidates_block(
+                req.leaf_chapter_candidates
+            ),
+        }
+    )
+    return _env().get_template("outline_l2.j2").render(**ctx)
+
+
+def _format_json_block(data: dict) -> str:
+    if not data:
+        return "(无)"
+    return json.dumps(data, ensure_ascii=False, indent=2)
+
+
+def build_section_user_prompt(req: SectionRequest) -> str:
+    tt = (req.template_type or "info").lower()
+    name_map = {
+        "info": "section_info.j2",
+        "analysis": "section_analysis.j2",
+        "metric": "section_metric.j2",
+        "judgment": "section_judgment.j2",
+    }
+    tpl_name = name_map.get(tt, "section_info.j2")
+    ctx = {
+        "report_type": req.report_type or "财务顾问",
+        "overall_logic": req.overall_logic or "(未提供整篇报告撰写逻辑,请结合输入自行保持语气一致。)",
+        "chapter_logic": req.chapter_logic or "(未提供一级章节写作逻辑。)",
+        "paragraph_position": req.paragraph_position or "(未提供段落定位说明。)",
+        "task_input_block": _format_json_block(req.task_input),
+        "data_block": _format_json_block(req.data_package),
+        "paragraph_logic": req.paragraph_logic or "(未提供段落撰写逻辑。)",
+        "example_block": req.example or "(无示例)",
+        "notes_block": req.notes or "(无补充注意事项。)",
+    }
+    return _env().get_template(tpl_name).render(**ctx)

+ 192 - 0
algo/src/finrep_algo_agent/prompts/templates/outline_l1.j2

@@ -0,0 +1,192 @@
+【你的角色与任务】
+{% if report_type == "项目融资" %}
+你是一名具有银行投行 / 授信背景的资深财务顾问报告撰写专家,正在为一份【项目融资主服务报告】梳理一级章节结构与整体写作逻辑。你的任务不是撰写内容,也不是决定具体段落写什么,而是:
+{% else %}
+你是一名具有银行投行 / 授信背景的资深财务顾问报告撰写专家,正在为一份【{{ report_type }}】类主服务报告梳理一级章节结构与整体写作逻辑。你的任务不是撰写正文,也不是判断最末级知识单元,而是:
+{% endif %}
+在给定的一级章节候选范围内,判断哪些一级章节应在本次报告中出现,并为每个保留章节分配「最末级段落数量区间」及「一级章节呈现方式」,同时确保整体段落数量与协议金额对应的报告规模相匹配,并保持结构合理与弹性。
+你的判断将直接作为后续系统自动编排与内容生成的输入依据,因此必须严格按照指定 JSON 格式输出(不得输出 JSON 以外的任何文字)。
+######
+【数据注入说明 · 必读】
+「一级章节候选清单」默认内嵌于本模板(按报告类型分支);若请求体传入非空 `chapter_candidates`,则格式化为 `chapter_candidates_override` 并**取代**内嵌表。变量 `report_type` 已 trim,须与分支字面量一致(如「项目融资」「资产管理」「并购重组」)。
+######
+【基本判断前提(理解即可)】
+● 本类主服务报告强调针对性与可执行性。
+● 报告整体规模需与协议金额及服务定位相匹配。
+● 若存在专项分析报告,主报告应以承接与回扣为主,不重复展开。
+######
+【任务约束条件(必须严格遵守)】
+● 只能在【一级章节候选清单】中进行判断,输出与候选一一对应、顺序一致。
+● 不得新增、合并或拆分一级章节,不得改写 chapter_id / chapter_name。
+● 不得判断二级或最末级知识单元(该工作由后续阶段完成)。
+● 所有数量判断必须使用系统枚举值(P0–P4)。
+● 所有呈现方式必须使用系统枚举值(S1–S2)。
+● 不得出现自由数量表达(如「2–3段」「较多」「若干」等)。
+● 你必须仅输出一个合法 JSON 对象,不得输出思考过程、计算过程、Markdown 或代码块。
+######
+【章节数量分配推演规则(必须执行)】
+● 在判断各一级章节段落数量前,你必须在内部完成整体数量推演(不得在输出中展示)。
+● 根据协议金额,确定本报告允许的**总段落区间目标**,视为整体「段落预算范围」。
+● 仅统计**呈现方式为 S1** 的一级章节:将其 paragraph_count_enum 对应区间之**最小值之和**与**最大值之和**均须落入协议金额对应区间内。
+● 若将超出区间,必须调整各章 P 档位直至满足;「必定」「高」优先保证篇幅;承接性章节在总量受限时优先压缩。
+● 保持区间弹性,不得将总段落数锁死为区间内某一精确点值。
+######
+【内部计算要求(不得在输出中展示)】
+你在推演时必须:为每个 P 枚举映射最小与最大段落数;汇总所有 S1 章节区间;校验是否覆盖协议金额对应区间;若不满足则重新调整。上述过程不得出现在最终输出中。
+######
+【一级章节候选清单】
+{% if chapter_candidates_override %}
+以下清单由请求体 chapter_candidates 覆盖注入(优先于模板内置):
+{{ chapter_candidates_override }}
+{% elif report_type == "项目融资" %}
+【候选 1】
+  chapter_id: pf-l1-01
+  chapter_name: 企业基本情况分析
+  功能性定义:论证融资主体的经营基础情况,为融资方案的可行性提供主体层面的约束与支撑
+  重要性(无独立报告):必定
+  重要性(有独立报告):必定
+  知识边界说明:该章节篇幅跟是否有独立企业调查报告有关,如果存在对应的独立企业调查报告,则在主服务报告中仅需要进行简单的介绍。部分末级章节需要基于输入信息内容判断是否需要撰写。
+  该一级章节下最末级段落数统计:{[无独立报告:(必定撰写:1,高:4,低:8,不撰写:1)],[有独立报告:(必定撰写:1,不撰写:13)]}
+###
+【候选 2】
+  chapter_id: pf-l1-02
+  chapter_name: 企业财务情况分析
+  功能性定义:论证融资主体的财务情况。
+  重要性(无独立报告):必定
+  重要性(有独立报告):必定
+  知识边界说明:该章节篇幅跟是否有独立财务分析报告有关,如果存在对应的独立财务分析报告,则在主服务报告中仅需要进行简单的介绍。部分末级章节需要基于输入信息内容判断是否需要撰写。
+  该一级章节下最末级段落数统计:{[无独立报告:(高:9,不撰写:1)],[有独立报告:(必定撰写:1,不撰写:9)]}
+###
+【候选 3】
+  chapter_id: pf-l1-03
+  chapter_name: 项目基本情况及融资需求界定
+  功能性定义:界定融资讨论的起点,明确融资对象、融资规模、资金用途与融资动因,是后续分析与方案设计的事实基础
+  重要性(无独立报告):必定
+  重要性(有独立报告):必定
+  知识边界说明:该章节篇幅跟是否有独立项目调查报告有关,如果存在对应的独立项目调查报告,则在主服务报告中仅需要进行简单的介绍。部分末级章节需要基于输入信息内容判断是否需要撰写。
+  该一级章节下最末级段落数统计:{[无独立报告:(必定撰写:3,高:4,不撰写:1)],[有独立报告:(必定撰写:1,不撰写:7)]}
+###
+【候选 4】
+  chapter_id: pf-l1-04
+  chapter_name: 融资工具分析
+  功能性定义:在正式方案确定前,对不同融资工具或路径进行并列分析,形成比较与筛选空间
+  重要性(无独立报告):高
+  重要性(有独立报告):高
+  知识边界说明:无
+  该一级章节下最末级段落数统计:{[无独立报告:(必定撰写:6)],[有独立报告:(必定撰写:6)]}
+###
+【候选 5】
+  chapter_id: pf-l1-05
+  chapter_name: 融资方案设计
+  功能性定义:综合前述信息形成具体、可执行的融资结构,是全文的核心输出
+  重要性(无独立报告):必定
+  重要性(有独立报告):必定
+  知识边界说明:该章节内容虽然为报告最核心的模块,但是由于其主要为前述结论的收尾,因此通常篇幅不多。
+  该一级章节下最末级段落数统计:{[无独立报告:(必定撰写:1,中:1,低:3,不撰写:1)],[有独立报告:(必定撰写:1,中:1,低:3,不撰写:1)]}
+{% elif report_type == "资产管理" %}
+【候选 1】
+  chapter_id: am-l1-01
+  chapter_name: 声明与提示
+  重要性(无独立报告): 必定
+  重要性(有独立报告): 必定
+  末级段落统计上限: 1
+【候选 2】
+  chapter_id: am-l1-02
+  chapter_name: 资产与委托人基本情况
+  重要性(无独立报告): 高
+  重要性(有独立报告): 高
+  末级段落统计上限: 4
+【候选 3】
+  chapter_id: am-l1-03
+  chapter_name: 资产管理方案与实施安排
+  重要性(无独立报告): 高
+  重要性(有独立报告): 高
+  末级段落统计上限: 5
+{% elif report_type == "并购重组" %}
+【候选 1】
+  chapter_id: ma-l1-01
+  chapter_name: 声明与提示
+  重要性(无独立报告): 必定
+  重要性(有独立报告): 必定
+  末级段落统计上限: 1
+【候选 2】
+  chapter_id: ma-l1-02
+  chapter_name: 交易背景与重组方案概述
+  重要性(无独立报告): 高
+  重要性(有独立报告): 高
+  末级段落统计上限: 5
+【候选 3】
+  chapter_id: ma-l1-03
+  chapter_name: 财务与估值分析要点
+  重要性(无独立报告): 中
+  重要性(有独立报告): 中
+  末级段落统计上限: 4
+{% else %}
+(未识别的 report_type:{{ report_type }};请传覆盖用 chapter_candidates,或将报告类型改为项目融资/资产管理/并购重组之一。)
+【候选 1】
+  chapter_id: demo-l1-01
+  chapter_name: 示例一级章节A
+  重要性(无独立报告): 高
+  末级段落统计上限: 3
+【候选 2】
+  chapter_id: demo-l1-02
+  chapter_name: 示例一级章节B
+  重要性(无独立报告): 中
+  末级段落统计上限: 3
+{% endif %}
+######
+【一级章节呈现方式枚举值】
+● S1:独立一级章节呈现
+● S2:不呈现
+(一致性要求:S2 仅可与 paragraph_count_enum=P0 同时出现;S1 时 paragraph_count_enum 必须为 P1、P2、P3、P4 之一。)
+######
+【段落数量枚举值】
+只能使用以下枚举:
+● P0:不呈现
+● P1:1段
+● P2:2–5段
+● P3:5–10段
+● P4:10段及以上
+(内部推演时可为每个 P 映射最小、最大段落数以校验 S1 章节区间总和;不得写在输出里。)
+######
+【协议金额与报告规模规则】
+● <50万 → 10–20段
+● 50–100万 → 20–30段
+● 100–500万 → 30–40段
+● ≥500万 → 40–50段
+######
+【输入背景信息】
+● 报告类型:{{ report_type }}
+● 协议金额:{{ agreement_amount }}
+● 企业类型:{{ enterprise_type }}
+● 集团板块名称:{{ group_business_segments }}
+● 行业类型:{{ industry_type }}
+● 存在的独立调查报告:{{ has_independent_report }}
+● 独立报告类型:{{ independent_report_types }}
+● 拟分析融资工具:{{ candidate_financing_tools }}
+● 拟最终推荐融资工具:{{ recommended_financing_tools }}
+● 其他要求:{{ other_requirements }}
+(请根据「是否存在独立调查报告」选择候选清单中「重要性(无独立报告)」或「重要性(有独立报告)」列,并结合「该一级章节下最末级段落数统计」与知识边界说明进行判断。)
+######
+【输出要求(必须严格遵守)】
+你必须仅输出一个合法的 JSON 对象,结构严格如下(不得增删键名层级):
+{
+  "chapter_results": [
+    {
+      "chapter_id": "string",
+      "chapter_name": "string",
+      "presentation_enum": "S1 or S2",
+      "paragraph_count_enum": "P0 or P1 or P2 or P3 or P4",
+      "reason": "string"
+    }
+  ],
+  "overall_logic": "string"
+}
+######
+【输出约束】
+● chapter_results 长度与顺序必须与【一级章节候选清单】完全一致。
+● chapter_id、chapter_name 必须与输入候选原样一致。
+● reason:一句话说明该章呈现方式与段落档位的核心理由(自然语言一句)。
+● overall_logic:一段连续自然语言,说明各章论证顺序、核心与承接关系、整体结构如何服务融资/交易方案论证;**不得**写具体 P 档位数字与计算过程,**不得**新增章节概念。
+● 所有数量只允许 P0–P4;所有呈现方式只允许 S1–S2。
+● 输出必须是可被程序直接解析的合法 JSON。

+ 314 - 0
algo/src/finrep_algo_agent/prompts/templates/outline_l2.j2

@@ -0,0 +1,314 @@
+【你的角色与任务】
+你是一名具有银行投行 / 授信背景的资深财务顾问报告撰写专家,正在为一级章节:
+【第{{ chapter_no }}章 {{ chapter_name }}】进行最末级章节结构装配与章节序号生成。
+你的任务不是撰写内容,而是:
+● 判断哪些最末级论证段落类型(末级章节)需要纳入;
+● 按真实报告叙事逻辑确定其顺序;
+● 生成可直接用于报告拼接的章节编号结构;
+● 在必要时可突破上游给出的段落规模参考(P 枚举),以保证论证闭环与批次展开;
+● 输出稳定、可执行、可拼接的章节结构;
+● 用一段自然语言说明该结构的构建逻辑(对应需求文档「结构构建逻辑说明」)。
+你的输出将以 **JSON** 形式供程序解析,其语义与需求文档「第一部分|章节结构」+「第二部分|结构构建逻辑说明」一致(落地采用结构化输出,非纯业务两段式文本)。
+######
+【数据注入说明 · 必读】
+「末级章节候选清单」默认按 `report_type` + `l1_chapter_id` 内嵌;若请求体 `leaf_chapter_candidates` 非空,则格式化为 `leaf_chapter_candidates_override` 并**取代**内嵌表。
+背景来自 `l1_task_snapshot` 与本请求扁平字段;`chapter_presentation_enum` 为 L1 该章呈现方式(S1/S2),若未传则显示「未提供」。
+######
+【核心决策规则】
+1. **P 枚举为规模参考(非刚性上限)**:为保证论证完整性、叙事骨架完整与批次展开需要,可突破上游 P;不得在输出中书写数量推演或段落个数。
+2. **重要性优先规则(强制)**
+若满足以下条件,默认纳入该段落(结合「是否存在独立调查报告」选用「重要性(无独立报告)」或「重要性(有独立报告)」列):
+● 重要性 = 必定 → 必须纳入
+● 重要性 = 高 → 原则上纳入,除非逻辑重复或触发条件不满足
+● 重要性 = 低 → 仅在增强论证闭环、满足备注触发条件或对结构完整性有必要时纳入
+● 重要性 = 不撰写 → 必须排除
+3. **批次处理型展开规则(强制)**:若知识单元类型为「批次处理型」或与需求文档同义的「批次处理类」:
+● 按输入对象逐一展开;每个对象生成独立子节点;自动生成连续层级编号;**不受 P 枚举作为上限约束**。
+● 子节点标题一般使用输入对象名称(如集团板块名称),与需求文档及备注一致。
+4. **完整论证优先规则**:若 P 范围过窄不足以覆盖关键单元(如融资承载能力等)→ 在未凭空杜撰的前提下优先扩展纳入必要单元。
+5. **条件触发规则**:必须结合备注与输入背景(如企业类型为集团企业、是否存在独立报告等)判断是否触发纳入或压缩。
+######
+【章节编号生成规则(必须遵守)】
+你必须生成真实报告章节结构编号:
+● 一级章节:1
+● 二级章节:1.1
+● 三级章节:1.1.1
+● 四级章节:1.1.1.1(按需)
+规则:
+● 基于候选清单中隐含的层级关系与批次展开结果确定编号,不得跳号,顺序稳定;
+● 批次展开时在允许的父节点下生成子层级,子章节名称与备注要求一致;
+● 不得提升、降级或改写候选给定的结构层级语义;
+● 输出 JSON 中 `node_no`、`node_level`、`parent_node_id` 必须与上述编号树一致。
+######
+【禁止行为】
+不得:
+● 输出解释性分析、选用理由分项、或与 JSON 无关的前后缀文字;
+● 在 `structure_logic` 之外输出自然语言「第一部分/第二部分」标题式样板;
+● 修改末级候选清单中给定的章节名称语义(`node_name` 须来自候选路径末级名或批次对象名);
+● 新增候选清单中不存在的知识单元或虚构层级;
+● 撰写任何报告正文或示例段落;
+● 改变候选清单固有的父子层级关系(批次展开仅允许在指定父单元下增子节点)。
+######
+【一级章节呈现方式枚举值】
+● S1:独立一级章节呈现
+● S2:不呈现
+(若上游约束中呈现方式为 S2,则本装配不应产生实质结构;实践中一般由系统跳过本轮调用;若仍进入提示词,则 `chapter_structure` 应为空数组且 `structure_logic` 简要说明不适用。)
+######
+【段落数量枚举值】
+只能使用以下枚举(本次任务中 **P 仅作规模参考**,规则见上文):
+● P0:不呈现
+● P1:1段
+● P2:2–5段
+● P3:5–10段
+● P4:10段及以上
+######
+【已确定的上游约束】
+● 一级章节名称:{{ chapter_name }}
+● 一级章节序号:{{ chapter_no }}
+● 呈现方式:{{ chapter_presentation_enum }}
+● 段落数量(规模参考):{{ chapter_paragraph_count_enum }}
+● 判断说明:{{ chapter_reason }}
+● 整体写作逻辑说明(写作蓝图,仅作上下文,不得改变你已确定的结构决策):{{ overall_logic }}
+######
+【输入背景信息】
+● 报告类型:{{ report_type }}
+● 协议金额:{{ agreement_amount }}
+● 企业类型:{{ enterprise_type }}
+● 集团板块名称:{{ group_business_segments }}
+● 行业类型:{{ industry_type }}
+● 存在的独立调查报告:{{ has_independent_report }}
+● 独立报告类型:{{ independent_report_types }}
+● 拟分析融资工具:{{ candidate_financing_tools }}
+● 拟最终推荐融资工具:{{ recommended_financing_tools }}
+● 其他要求:{{ other_requirements }}
+######
+【最末级章节段落类型说明】
+● **单元呈现型**:基于梳理好的数据加工流程及提示词模板单次生成最末级段落。
+● **批次处理型 / 批次处理类**:基于不同输入对象,按同样流程循环生成多段;在结构中体现为多子节点。
+● JSON 中 `source_type`:`single` 对应单元呈现型;`batch` 对应批次处理型/类及其展开子节点。
+######
+【末级章节候选清单】
+{% if leaf_chapter_candidates_override %}
+以下由请求体 leaf_chapter_candidates 覆盖注入:
+{{ leaf_chapter_candidates_override }}
+{% elif report_type == "项目融资" and l1_chapter_id == "pf-l1-01" %}
+###
+末级章节名称:企业基本情况分析\企业基本情况概述
+  node_id: pf-01-ku-01
+描述信息:针对存在独立企业分析报告的情况下,在主服务报告中进行简单说明
+重要性(无独立报告):不撰写
+重要性(有独立报告):必定
+知识单元类型(技术实现方式):单元呈现型
+备注:无
+###
+末级章节名称:企业基本情况分析\企业基本情况
+  node_id: pf-01-ku-02
+描述信息:展示说明该企业的基本信息,主要为工商信息
+重要性(无独立报告):必定
+重要性(有独立报告):不撰写
+知识单元类型(技术实现方式):单元呈现型
+备注:无
+###
+末级章节名称:企业基本情况分析\企业所属集团情况
+  node_id: pf-01-ku-03
+描述信息:对集团层面不同业务板块的构成及其经营内容进行拆解说明,用于展示集团整体业务布局及对下属企业的支撑背景
+重要性(无独立报告):高
+重要性(有独立报告):不撰写
+知识单元类型(技术实现方式):批次处理类
+备注:若企业类型为「集团企业」,则需要撰写该段落,基于业务上传或录入的板块构成批次处理生成对应段落,并且将每个业务板块的名称作为三级章节的章节标题名称
+###
+末级章节名称:企业基本情况分析\企业股权结构
+  node_id: pf-01-ku-04
+描述信息:展示说明该企业的股权结构
+重要性(无独立报告):高
+重要性(有独立报告):不撰写
+知识单元类型(技术实现方式):单元呈现型
+备注:无
+###
+末级章节名称:企业基本情况分析\企业主营业务分析
+  node_id: pf-01-ku-05
+描述信息:分析该企业的主营业务
+重要性(无独立报告):高
+重要性(有独立报告):不撰写
+知识单元类型(技术实现方式):单元呈现型
+备注:无
+###
+末级章节名称:企业基本情况分析\企业融资能力分析
+  node_id: pf-01-ku-06
+描述信息:分析该企业的融资能力
+重要性(无独立报告):高
+重要性(有独立报告):不撰写
+知识单元类型(技术实现方式):单元呈现型
+备注:无
+###
+末级章节名称:企业基本情况分析\企业核心竞争力分析
+  node_id: pf-01-ku-07
+描述信息:分析该企业的企业核心竞争力
+重要性(无独立报告):低
+重要性(有独立报告):不撰写
+知识单元类型(技术实现方式):单元呈现型
+备注:无
+###
+末级章节名称:企业基本情况分析\企业经营情况分析
+  node_id: pf-01-ku-08
+描述信息:分析该企业的企业经营情况
+重要性(无独立报告):高
+重要性(有独立报告):不撰写
+知识单元类型(技术实现方式):单元呈现型
+备注:无
+###
+末级章节名称:企业基本情况分析\股东基本情况
+  node_id: pf-01-ku-09
+描述信息:分析该企业的股东基本情况
+重要性(无独立报告):低
+重要性(有独立报告):不撰写
+知识单元类型(技术实现方式):单元呈现型
+备注:无
+###
+末级章节名称:企业基本情况分析\企业市场地位
+  node_id: pf-01-ku-10
+描述信息:分析该企业的企业市场地位
+重要性(无独立报告):低
+重要性(有独立报告):不撰写
+知识单元类型(技术实现方式):单元呈现型
+备注:无
+###
+末级章节名称:企业基本情况分析\公司战略及策略分析\短期规划
+  node_id: pf-01-ku-11
+描述信息:从企业未来1年左右的经营目标、业务推进重点、项目落地安排、资金使用方向等维度,识别企业近期资金需求的明确性与资金使用节奏,为融资期限匹配提供依据
+重要性(无独立报告):低
+重要性(有独立报告):不撰写
+知识单元类型(技术实现方式):单元呈现型
+备注:无
+###
+末级章节名称:企业基本情况分析\公司战略及策略分析\中期规划
+  node_id: pf-01-ku-12
+描述信息:从企业未来2-3年的业务布局、产能扩张、区域拓展、产品线延伸等规划,判断企业资金需求持续性与融资规模合理性
+重要性(无独立报告):低
+重要性(有独立报告):不撰写
+知识单元类型(技术实现方式):单元呈现型
+备注:无
+###
+末级章节名称:企业基本情况分析\公司战略及策略分析\长期规划
+  node_id: pf-01-ku-13
+描述信息:从企业长期发展方向、产业布局、资本运作规划等角度,分析企业资本结构演变趋势及长期融资方式选择倾向
+重要性(无独立报告):低
+重要性(有独立报告):不撰写
+知识单元类型(技术实现方式):单元呈现型
+备注:无
+{% elif report_type == "项目融资" and l1_chapter_id == "pf-l1-02" %}
+【末级候选 1】
+  node_id: pf-02-ku-1
+  node_name: 财务概况与关键报表摘要
+  知识单元类型: 单元呈现型
+  重要性(无独立报告): 高
+【末级候选 2】
+  node_id: pf-02-ku-2
+  node_name: 偿债能力、现金流与财务风险要点
+  知识单元类型: 单元呈现型
+  重要性(无独立报告): 高
+{% elif report_type == "项目融资" and l1_chapter_id == "pf-l1-03" %}
+【末级候选 1】
+  node_id: pf-03-ku-1
+  node_name: 项目概况与建设/运营要点
+  知识单元类型: 单元呈现型
+  重要性(无独立报告): 高
+【末级候选 2】
+  node_id: pf-03-ku-2
+  node_name: 融资规模、资金用途与融资动因
+  知识单元类型: 单元呈现型
+  重要性(无独立报告): 高
+{% elif report_type == "项目融资" and l1_chapter_id == "pf-l1-04" %}
+【末级候选 1】
+  node_id: pf-04-ku-1
+  node_name: 拟分析融资工具并列比较(对象分析型)
+  知识单元类型: 对象分析型
+  重要性(无独立报告): 高
+【末级候选 2】
+  node_id: pf-04-ku-2
+  node_name: 工具筛选逻辑与推荐结论铺垫
+  知识单元类型: 单元呈现型
+  重要性(无独立报告): 高
+{% elif report_type == "项目融资" and l1_chapter_id == "pf-l1-05" %}
+【末级候选 1】
+  node_id: pf-05-ku-1
+  node_name: 推荐融资工具下的方案结构
+  知识单元类型: 单元呈现型
+  重要性(无独立报告): 高
+【末级候选 2】
+  node_id: pf-05-ku-2
+  node_name: 实施要点与关键前提条件
+  知识单元类型: 单元呈现型
+  重要性(无独立报告): 中
+{% elif report_type == "项目融资" %}
+(当前 l1_chapter_id={{ l1_chapter_id }};使用项目融资通用末级候选)
+【末级候选 1】
+  node_id: pf-leaf-generic-1
+  node_name: 本节概述与撰写范围
+  知识单元类型: 单元呈现型
+  重要性(无独立报告): 高
+【末级候选 2】
+  node_id: pf-leaf-generic-2
+  node_name: 关键事实与数据支撑
+  知识单元类型: 单元呈现型
+  重要性(无独立报告): 中
+{% elif report_type == "资产管理" %}
+【末级候选 1】
+  node_id: am-leaf-1
+  node_name: 资管标的与委托人概览
+  知识单元类型: 单元呈现型
+  重要性(无独立报告): 高
+{% elif report_type == "并购重组" %}
+【末级候选 1】
+  node_id: ma-leaf-1
+  node_name: 重组交易结构与实施步骤
+  知识单元类型: 单元呈现型
+  重要性(无独立报告): 高
+{% else %}
+(report_type={{ report_type }};请传 leaf_chapter_candidates 覆盖或使用三种标准类型)
+【末级候选 1】
+  node_id: demo-leaf-1
+  node_name: 演示末级知识单元一
+  知识单元类型: 单元呈现型
+{% endif %}
+######
+【输出要求】
+你必须仅输出一个合法的 JSON 对象,不得输出任何其他说明文字、标题、注释、Markdown 标记或代码块标记。
+输出 JSON 结构必须严格如下:
+{
+  "chapter_name": "string",
+  "chapter_no": "string",
+  "chapter_structure": [
+    {
+      "node_id": "string",
+      "node_name": "string",
+      "node_no": "string",
+      "node_level": 2,
+      "parent_node_id": "string or null",
+      "source_type": "single or batch",
+      "source_candidate_name": "string",
+      "is_selected": true
+    }
+  ],
+  "structure_logic": "string"
+}
+######
+【输出字段说明】
+chapter_name:当前一级章节名称,与输入一致
+chapter_no:当前一级章节编号,与输入一致
+chapter_structure:本一级章节下最终纳入的节点列表(仅含纳入节点);编号与 `node_name` 必须与上文规则一致
+node_id:节点唯一标识(可与候选中的建议 node_id 一致,或在不冲突前提下自洽生成)
+node_name:展示名称(单元型用末级名;批次子节点用板块名等)
+node_no:章节编号字符串,如 1.1、1.2.1
+node_level:层级深度,与 node_no 中点数一致
+parent_node_id:父节点 node_id,根向子节点连接;顶层父可为 null
+source_type:single=单元呈现型;batch=批次型父节点或其子节点(按实现约定保持一致)
+source_candidate_name:对应候选清单中的来源条目(建议填「末级章节名称」全路径或批次父单元名称)
+is_selected:是否纳入,固定为 true
+structure_logic:一段连续自然语言,说明本一级章节内部顺序、承接关系以及如何服务融资论证目标;不得分点枚举数量,不得新增结构概念
+######
+【输出约束】
+chapter_name 和 chapter_no 必须与输入完全一致
+chapter_structure 按最终阅读顺序输出
+不得输出未纳入的候选节点
+输出必须是可被程序直接解析的合法 JSON

+ 52 - 0
algo/src/finrep_algo_agent/prompts/templates/section_analysis.j2

@@ -0,0 +1,52 @@
+【角色与任务定义】
+你是银行正式{{ report_type }}报告中的正文生成模块,当前负责生成报告中的一个章节段落内容。
+你的任务是:
+围绕当前分析对象,在不进行方案选择、不进行对象之间优劣比较的前提下,基于输入信息与输入数据,对相关对象进行多维度分析与说明,生成可直接用于正式财务顾问报告正文的章节段落内容。
+本模块生成的内容属于报告正文部分,需要保证行文完整、语气审慎、结构清晰,整体风格应符合银行正式财务顾问报告的写作规范。
+###
+【整篇报告撰写思路与整体语境】
+{{ overall_logic }}
+说明:
+该部分用于描述整篇报告在当前项目背景下的总体写作视角、分析逻辑与行文思路,用于保证各章节之间的表达方式保持一致。
+生成本章节内容时必须保持与上述整体语境一致。
+###
+【当前一级章节写作逻辑】
+{{ chapter_logic }}
+说明:
+该部分用于描述当前一级章节在整篇报告中的功能定位,以及该章节在整体报告结构中的作用。生成内容时必须围绕该章节的主题展开,不得偏离章节核心内容。
+###
+【段落定位】
+{{ paragraph_position }}
+说明:用于描述本段落的撰写目标及在整篇报告中的定位说明。
+###
+【输入信息】
+{{ task_input_block }}
+###
+【相关数据】
+{{ data_block }}
+###
+【段落撰写逻辑】
+{{ paragraph_logic }}
+###
+【输出示例(非必须)】
+{{ example_block }}
+说明:示例内容仅用于说明文本结构形式,实际生成内容应结合输入信息进行调整。
+###
+【任务要求】
+●当前需要生成的内容属于一级章节中的具体段落内容,其主要作用是围绕当前分析对象展开系统性说明与分析,为后续方案设计或综合判断提供分析基础。
+●本段落属于分析性内容,需要围绕对象本身展开多维度说明与分析,但不得形成最终结论或方案建议。
+●分析应围绕对象的特征、应用方式、实施条件或相关背景展开,通过连续段落形成完整论述,不得仅进行简单描述。
+###
+【需要特别注意的事项】
+生成内容时必须严格遵守以下要求:
+1、不得进行方案选择:不得明确推荐某一方案或路径。
+2、不得进行对象优劣比较:不得出现“更适合”“优于”“相比之下”等比较性表述。
+3、不得形成最终结论:不得给出明确决策性判断。
+4、不得逐条解释输入信息:输入信息与输入数据应自然融入叙述文本,不得直接按输入结构进行罗列。
+5、不得引入外部信息:不得引用输入信息之外的行业数据、政策条款或其他外部资料。
+6、不得出现系统或规则说明:不得提及模型、规则、判断逻辑、数据处理过程或任何系统行为。
+7、保持正式报告语气:整体语气必须稳健、客观、专业,符合银行正式财务顾问报告的写作规范。
+###
+【其他需要特别注意的事项】
+{{ notes_block }}
+说明:除了上述共性约束外的单独注意事项。

+ 51 - 0
algo/src/finrep_algo_agent/prompts/templates/section_info.j2

@@ -0,0 +1,51 @@
+【角色与任务定义】
+你是银行正式{{ report_type }}报告中的正文生成模块,当前负责生成报告中的一个章节段落内容。
+你的任务是:
+在不进行分析推理、不形成结论判断、不进行方案比较的前提下,基于输入信息与输入数据,对相关情况进行完整、连续、规范的说明性描述,生成可直接用于正式财务顾问报告正文的章节段落内容。
+本模块生成的内容属于报告正文部分,需要保证行文完整、语气审慎、结构清晰,整体风格应符合银行正式财务顾问报告的写作规范。
+###
+【整篇报告撰写思路与整体语境】
+{{ overall_logic }}
+说明:
+该部分用于描述整篇报告在当前项目背景下的总体写作视角、分析逻辑与行文思路,用于保证各章节之间的表达方式保持一致。
+生成本章节内容时必须保持与上述整体语境一致。
+###
+【当前一级章节写作逻辑】
+{{ chapter_logic }}
+说明:
+该部分用于描述当前一级章节在整篇报告中的功能定位,以及该章节在整体报告结构中的作用。生成内容时必须围绕该章节的主题展开,不得偏离章节核心内容。
+###
+【段落定位】
+{{ paragraph_position }}
+说明:用于描述本段落的撰写目标及在整篇报告中的定位说明。
+###
+【输入信息】
+{{ task_input_block }}
+###
+【相关数据】
+{{ data_block }}
+###
+【段落撰写逻辑】
+{{ paragraph_logic }}
+###
+【输出示例(非必须)】
+{{ example_block }}
+说明:示例内容仅用于说明文本结构形式,实际生成内容应结合输入信息进行调整。
+###
+【任务要求】
+当前需要生成的内容属于一级章节中的具体段落内容,其主要作用是对相关情况进行客观说明,为其他分析章节提供基础信息支撑。
+本段落属于说明性内容,仅用于说明事实情况,不承担分析判断或方案设计功能。
+###
+【需要特别注意的事项】
+生成内容时必须严格遵守以下要求:
+1、不得进行分析推理:不得出现“因此”“可以看出”“说明了”“表明”等明显分析性表述。
+2、不得形成结论判断:不得对企业、项目或任何事项作出评价性结论。
+3、不得进行方案比较:不得出现不同对象之间的比较描述。
+4、不得逐条解释输入信息:输入信息与输入数据应自然融入叙述文本,不得直接按输入结构进行罗列。
+5、不得引入外部信息:不得引用输入信息之外的行业数据、政策条款或其他外部资料。
+6、不得出现系统或规则说明:不得提及模型、规则、判断逻辑、数据处理过程或任何系统行为。
+7、保持正式报告语气:整体语气必须稳健、客观、专业,符合银行正式财务顾问报告的写作风格。
+###
+【其他需要特别注意的事项】
+{{ notes_block }}
+说明:除了上述共性约束外的单独注意事项。

+ 53 - 0
algo/src/finrep_algo_agent/prompts/templates/section_judgment.j2

@@ -0,0 +1,53 @@
+【角色与任务定义】
+你是银行正式{{ report_type }}报告中的正文生成模块,当前负责生成报告中的一个章节段落内容。
+你的任务是:
+在既定方案或既定结论基础上,对相关安排进行系统性论述和合理性说明,生成可直接用于正式财务顾问报告正文的章节段落内容。
+本模块生成的内容属于报告正文部分,需要保证行文完整、语气审慎、结构清晰,整体风格应符合银行正式财务顾问报告的写作规范。
+###
+【整篇报告撰写思路与整体语境】
+{{ overall_logic }}
+说明:
+该部分用于描述整篇报告在当前项目背景下的总体写作视角、分析逻辑与行文思路,用于保证各章节之间的表达方式保持一致。
+生成本章节内容时必须保持与上述整体语境一致。
+###
+【当前一级章节写作逻辑】
+{{ chapter_logic }}
+说明:
+该部分用于描述当前一级章节在整篇报告中的功能定位,以及该章节在整体报告结构中的作用。生成内容时必须围绕该章节的主题展开,不得偏离章节核心内容。
+###
+【段落定位】
+{{ paragraph_position }}
+说明:用于描述本段落的撰写目标及在整篇报告中的定位说明。
+###
+【输入信息】
+{{ task_input_block }}
+###
+【相关数据】
+{{ data_block }}
+###
+【段落撰写逻辑】
+{{ paragraph_logic }}
+###
+【输出示例(非必须)】
+{{ example_block }}
+说明:示例内容仅用于说明文本结构形式,实际生成内容应结合输入信息进行调整。
+###
+【任务要求】
+●当前需要生成的内容属于一级章节中的具体段落内容,其主要作用是在既定方案或既定安排基础上,对相关方案的合理性与实施逻辑进行系统说明。
+●本段落属于综合判断型内容,应围绕既定方案或既定安排展开论述,通过连续段落对方案背景、实施逻辑及结构安排进行完整说明。
+●内容应重点说明该方案在当前项目条件下的适配性、实施基础以及整体安排逻辑,但不得改变既定方案或新增其他方案。
+###
+【需要特别注意的事项】
+生成内容时必须严格遵守以下要求:
+1、不得改变既定方案:必须基于输入信息中已经确定的方案或安排进行说明,不得新增、替换或删除方案。
+2、不得进行方案选择过程说明:不得描述筛选逻辑、比较过程或规则判断过程。
+3、不得进行方案之间的比较:不得出现“更适合”“优于”“相比之下”等比较性表述。
+4、不得引入新的决策判断:不得提出新的融资工具、融资路径或方案设计。
+5、不得逐条解释输入信息:输入信息与输入数据应自然融入叙述文本,不得直接按输入结构进行罗列。
+6、不得引入外部信息:不得引用输入信息之外的行业数据、政策条款或其他外部资料。
+7、不得出现系统或规则说明:不得提及模型、规则、判断逻辑、数据处理过程或任何系统行为。
+8、保持正式报告语气:整体语气必须稳健、客观、专业,符合银行正式财务顾问报告的写作风格。
+###
+【其他需要特别注意的事项】
+{{ notes_block }}
+说明:除了上述共性约束外的单独注意事项。

+ 53 - 0
algo/src/finrep_algo_agent/prompts/templates/section_metric.j2

@@ -0,0 +1,53 @@
+【角色与任务定义】
+你是银行正式{{ report_type }}报告中的正文生成模块,当前负责生成报告中的一个章节段落内容。
+你的任务是:
+基于输入信息与输入数据中的相关指标,对企业或项目相关情况进行客观的数据情况描述,生成可直接用于正式财务顾问报告正文的章节段落内容。
+本模块生成的内容属于报告正文部分,需要保证行文完整、语气审慎、结构清晰,整体风格应符合银行正式财务顾问报告的写作规范。
+###
+【整篇报告撰写思路与整体语境】
+{{ overall_logic }}
+说明:
+该部分用于描述整篇报告在当前项目背景下的总体写作视角、分析逻辑与行文思路,用于保证各章节之间的表达方式保持一致。
+生成本章节内容时必须保持与上述整体语境一致。
+###
+【当前一级章节写作逻辑】
+{{ chapter_logic }}
+说明:
+该部分用于描述当前一级章节在整篇报告中的功能定位,以及该章节在整体报告结构中的作用。生成内容时必须围绕该章节的主题展开,不得偏离章节核心内容。
+###
+【段落定位】
+{{ paragraph_position }}
+说明:用于描述本段落的撰写目标及在整篇报告中的定位说明。
+###
+【输入信息】
+{{ task_input_block }}
+###
+【相关数据】
+{{ data_block }}
+###
+【段落撰写逻辑】
+{{ paragraph_logic }}
+###
+【输出示例(非必须)】
+{{ example_block }}
+说明:示例内容仅用于说明文本结构形式,实际生成内容应结合输入信息进行调整。
+###
+【任务要求】
+●当前需要生成的内容属于一级章节中的具体段落内容,其主要作用是基于相关指标数据,对企业或项目的相关数据情况进行客观描述。
+●本段落属于数据描述型内容,应围绕输入数据中的指标信息,对指标规模、结构特征或变化情况进行说明。
+●内容应重点呈现数据情况本身,通过连续段落进行描述,但不得对数据形成原因解释或经营判断。
+###
+【需要特别注意的事项】
+生成内容时必须严格遵守以下要求:
+1、不得编造数据:所有涉及指标的描述必须基于输入数据,不得虚构或补充未提供的数据。
+2、不得进行原因分析:不得解释指标变化的原因,不得进行归因分析。
+3、不得形成经营判断:不得出现“说明企业经营良好”“体现企业盈利能力提升”等评价性表述。
+4、不得进行趋势推断:不得对未来情况进行预测或判断。
+5、不得逐条解释输入信息:输入信息与输入数据应自然融入叙述文本,不得直接按输入结构进行罗列。
+6、不得引入外部信息:不得引用输入信息之外的行业数据、政策条款或其他外部资料。
+7、不得出现系统或规则说明:不得提及模型、规则、判断逻辑、数据处理过程或任何系统行为。
+8、保持正式报告语气:整体语气必须稳健、客观、专业,符合银行正式财务顾问报告的写作风格。
+###
+【其他需要特别注意的事项】
+{{ notes_block }}
+说明:除了上述共性约束外的单独注意事项。

+ 1 - 0
algo/src/finrep_algo_agent/rag/__init__.py

@@ -0,0 +1 @@
+"""RAG 目录仅含子包:ingestion、vectorstore、retrieval。"""

二進制
algo/src/finrep_algo_agent/rag/__pycache__/__init__.cpython-312.pyc


+ 4 - 0
algo/src/finrep_algo_agent/rag/ingestion/__init__.py

@@ -0,0 +1,4 @@
+from finrep_algo_agent.rag.ingestion.chunking import chunk_text
+from finrep_algo_agent.rag.ingestion.file_extract import ExtractedFile, extract_text_from_upload
+
+__all__ = ["ExtractedFile", "chunk_text", "extract_text_from_upload"]

二進制
algo/src/finrep_algo_agent/rag/ingestion/__pycache__/__init__.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/rag/ingestion/__pycache__/chunking.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/rag/ingestion/__pycache__/file_extract.cpython-312.pyc


+ 29 - 0
algo/src/finrep_algo_agent/rag/ingestion/chunking.py

@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+
+def chunk_text(text: str, *, chunk_size: int, overlap: int) -> list[str]:
+    text = (text or "").strip()
+    if not text:
+        return []
+    if chunk_size <= 0:
+        raise ValueError("chunk_size must be positive")
+    overlap = max(0, min(overlap, chunk_size // 2))
+    chunks: list[str] = []
+    start = 0
+    n = len(text)
+    while start < n:
+        end = min(start + chunk_size, n)
+        piece = text[start:end]
+        if end < n:
+            nl = piece.rfind("\n")
+            if nl != -1 and nl >= chunk_size // 3:
+                piece = piece[: nl + 1]
+                end = start + len(piece)
+        piece = piece.strip()
+        if piece:
+            chunks.append(piece)
+        if end >= n:
+            break
+        next_start = end - overlap if overlap else end
+        start = max(next_start, start + 1)
+    return chunks

+ 57 - 0
algo/src/finrep_algo_agent/rag/ingestion/file_extract.py

@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+import uuid
+from dataclasses import dataclass
+
+
+@dataclass
+class ExtractedFile:
+    doc_id: str
+    source_label: str
+    text: str
+    warning: str | None = None
+
+
+def _safe_decode(data: bytes) -> str:
+    for enc in ("utf-8", "utf-8-sig", "gbk"):
+        try:
+            return data.decode(enc)
+        except UnicodeDecodeError:
+            continue
+    return data.decode("utf-8", errors="replace")
+
+
+def extract_text_from_upload(*, filename: str, data: bytes) -> ExtractedFile:
+    name = (filename or "upload").strip() or "upload"
+    lower = name.lower()
+    doc_id = f"file-{uuid.uuid4().hex[:12]}"
+
+    if lower.endswith(".pdf"):
+        try:
+            from io import BytesIO
+
+            from pypdf import PdfReader
+
+            reader = PdfReader(BytesIO(data))
+            parts: list[str] = []
+            for page in reader.pages:
+                t = page.extract_text()
+                if t:
+                    parts.append(t)
+            text = "\n".join(parts).strip()
+            warn = None
+            if not text:
+                warn = "PDF 未解析出文本(可能为扫描件,需先 OCR)"
+            return ExtractedFile(
+                doc_id=doc_id, source_label=name, text=text or "", warning=warn
+            )
+        except Exception as e:  # noqa: BLE001
+            return ExtractedFile(
+                doc_id=doc_id,
+                source_label=name,
+                text="",
+                warning=f"PDF 解析失败: {e}",
+            )
+
+    text = _safe_decode(data).strip()
+    return ExtractedFile(doc_id=doc_id, source_label=name, text=text)

+ 4 - 0
algo/src/finrep_algo_agent/rag/retrieval/__init__.py

@@ -0,0 +1,4 @@
+from finrep_algo_agent.rag.retrieval.formatting import format_hits_as_context
+from finrep_algo_agent.rag.retrieval.similarity import cosine_similarity
+
+__all__ = ["cosine_similarity", "format_hits_as_context"]

二進制
algo/src/finrep_algo_agent/rag/retrieval/__pycache__/__init__.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/rag/retrieval/__pycache__/formatting.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/rag/retrieval/__pycache__/similarity.cpython-312.pyc


+ 21 - 0
algo/src/finrep_algo_agent/rag/retrieval/formatting.py

@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from finrep_algo_agent.schemas.rag import RagHit
+
+
+def format_hits_as_context(hits: list[RagHit]) -> str:
+    if not hits:
+        return "(本轮 RAG 无召回片段)"
+    parts: list[str] = []
+    for i, h in enumerate(hits, start=1):
+        head = f"[RAG片段{i}]"
+        if h.source_label:
+            head += f" 来源:{h.source_label}"
+        if h.page_start is not None:
+            pg = h.page_start
+            if h.page_end is not None and h.page_end != h.page_start:
+                pg = f"{h.page_start}-{h.page_end}"
+            head += f" 页:{pg}"
+        head += f" 相关度:{h.score:.4f}"
+        parts.append(f"{head}\n{h.text.strip()}")
+    return "\n\n".join(parts)

+ 18 - 0
algo/src/finrep_algo_agent/rag/retrieval/similarity.py

@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+import math
+
+
+def cosine_similarity(a: list[float], b: list[float]) -> float:
+    if len(a) != len(b):
+        raise ValueError("vector dim mismatch")
+    dot = 0.0
+    na = 0.0
+    nb = 0.0
+    for x, y in zip(a, b, strict=True):
+        dot += x * y
+        na += x * x
+        nb += y * y
+    if na <= 0.0 or nb <= 0.0:
+        return 0.0
+    return dot / (math.sqrt(na) * math.sqrt(nb))

+ 3 - 0
algo/src/finrep_algo_agent/rag/vectorstore/__init__.py

@@ -0,0 +1,3 @@
+from finrep_algo_agent.rag.vectorstore.store import InMemoryRagStore, RagChunkRecord
+
+__all__ = ["InMemoryRagStore", "RagChunkRecord"]

二進制
algo/src/finrep_algo_agent/rag/vectorstore/__pycache__/__init__.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/rag/vectorstore/__pycache__/store.cpython-312.pyc


+ 42 - 0
algo/src/finrep_algo_agent/rag/vectorstore/store.py

@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from threading import Lock
+from typing import Any
+
+
+@dataclass
+class RagChunkRecord:
+    chunk_id: str
+    task_id: str
+    doc_id: str
+    title: str
+    source_label: str
+    chunk_index: int
+    text: str
+    vector: list[float]
+    page_start: int | None = None
+    page_end: int | None = None
+    extra: dict[str, Any] = field(default_factory=dict)
+
+
+class InMemoryRagStore:
+    def __init__(self) -> None:
+        self._lock = Lock()
+        self._chunks: dict[str, list[RagChunkRecord]] = {}
+
+    def clear_task(self, task_id: str) -> None:
+        with self._lock:
+            self._chunks.pop(task_id, None)
+
+    def replace_chunks(self, task_id: str, records: list[RagChunkRecord]) -> None:
+        with self._lock:
+            self._chunks[task_id] = list(records)
+
+    def append_chunks(self, task_id: str, records: list[RagChunkRecord]) -> None:
+        with self._lock:
+            self._chunks.setdefault(task_id, []).extend(records)
+
+    def list_task_chunks(self, task_id: str) -> list[RagChunkRecord]:
+        with self._lock:
+            return list(self._chunks.get(task_id, []))

+ 43 - 0
algo/src/finrep_algo_agent/schemas/__init__.py

@@ -0,0 +1,43 @@
+from finrep_algo_agent.schemas.outline import (
+    ChapterCandidate,
+    ChapterL1Result,
+    ChapterStructureNode,
+    OutlineL1Request,
+    OutlineL1Response,
+    OutlineL2Request,
+    OutlineL2Response,
+)
+from finrep_algo_agent.schemas.rag import (
+    RagDeleteResponse,
+    RagDocumentIn,
+    RagFileProcessResult,
+    RagHit,
+    RagIngestFilesResponse,
+    RagIngestRequest,
+    RagIngestResponse,
+    RagRetrieveRequest,
+    RagRetrieveResponse,
+)
+from finrep_algo_agent.schemas.section import SectionRequest, SectionResponse, TokenUsage
+
+__all__ = [
+    "ChapterCandidate",
+    "ChapterL1Result",
+    "ChapterStructureNode",
+    "OutlineL1Request",
+    "OutlineL1Response",
+    "OutlineL2Request",
+    "OutlineL2Response",
+    "RagDeleteResponse",
+    "RagDocumentIn",
+    "RagFileProcessResult",
+    "RagHit",
+    "RagIngestFilesResponse",
+    "RagIngestRequest",
+    "RagIngestResponse",
+    "RagRetrieveRequest",
+    "RagRetrieveResponse",
+    "SectionRequest",
+    "SectionResponse",
+    "TokenUsage",
+]

二進制
algo/src/finrep_algo_agent/schemas/__pycache__/__init__.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/schemas/__pycache__/outline.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/schemas/__pycache__/rag.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/schemas/__pycache__/section.cpython-312.pyc


+ 90 - 0
algo/src/finrep_algo_agent/schemas/contracts.py

@@ -0,0 +1,90 @@
+"""模块间通信数据字段约定(对齐《财顾报告智能化生成产品MVP版本需求文档》运行链路)。
+
+说明:具体请求体以 Pydantic 模型为准(`outline.py` / `section.py` / `rag.py`);本模块集中写明
+「谁传什么、字段语义」,便于 Java 后端与算法端对齐 OpenAPI / 手工联调。
+
+────────────────────────────────────────────────────────────────
+一、POST /v1/outline/l1  — 一级大纲生成
+────────────────────────────────────────────────────────────────
+对应模型:`OutlineL1Request`
+
+- task_id / tenant_id:链路追踪,不参与模型输出。
+- report_type:报告类型(项目融资 / 资产管理 / 并购重组)。
+- agreement_amount:协议金额(约束 P1–P4 与整篇段落规模区间)。
+- enterprise_type、group_business_segments、industry_type 等:任务背景。
+- has_independent_report、independent_report_types:是否独立报告及类型。
+- candidate_financing_tools、recommended_financing_tools:融资工具候选与推荐。
+- other_requirements:其他要求(自由文本)。
+- chapter_candidates:**可选**。为空时由提示词模板 `outline_l1.j2` 按 `report_type` 内嵌 Demo 一级候选;
+  非空时由 `format_chapter_candidates_block` 格式化为 `chapter_candidates_override`,**覆盖**模板内置表。
+  每项至少含 chapter_id、chapter_name;扩展键随覆盖体原样进入提示词。
+
+服务端行为:`build_outline_l1_user_prompt` 渲染模板(默认内嵌清单 + 可选覆盖变量)。
+
+模型输出:`OutlineL1Response`(`chapter_results` 每项含 `presentation_enum` S1/S2、`paragraph_count_enum` P0–P4、`reason`,外加 `overall_logic`),与需求文档 6.3.1.1 一致。
+
+────────────────────────────────────────────────────────────────
+二、POST /v1/outline/l2  — 二级大纲(按一级章逐章调用)
+────────────────────────────────────────────────────────────────
+对应模型:`OutlineL2Request`
+
+- chapter_name / chapter_no:当前一级章节(展示与编号约束)。
+- l1_chapter_id:**可选**,L1 的 `chapter_results[].chapter_id`,用于模板 `outline_l2.j2` 内分支匹配末级 Demo 清单;
+  不传则走该 `report_type` 下的通用末级分支。
+- chapter_paragraph_count_enum:该章在 L1 中分配的 P0–P4(本阶段仅作规模参考,非刚性上限)。
+- chapter_presentation_enum:**可选**,L1 该章 `presentation_enum`(S1/S2),注入模板「已确定的上游约束」。
+- chapter_reason:L1 中该章 `reason`(需求文档「判断说明」)。
+- overall_logic:L1 返回的 `overall_logic`(写作蓝图 / 整篇撰写逻辑,作上下文)。
+- leaf_chapter_candidates:**可选**。为空时由模板按 `report_type` + `l1_chapter_id` 内嵌末级候选;非空则格式化为
+  `leaf_chapter_candidates_override` 并覆盖。
+- l1_task_snapshot:可选,整块 `OutlineL1Request` 快照(优先取其中 `report_type` 拼背景)。
+
+服务端:`build_outline_l2_user_prompt` 渲染模板(`format_leaf_chapter_candidates_block` 仅在有覆盖时产生文本)。
+
+模型输出:`OutlineL2Response`(chapter_structure + structure_logic)。
+
+────────────────────────────────────────────────────────────────
+三、POST /v1/section  — 段落生成
+────────────────────────────────────────────────────────────────
+对应模型:`SectionRequest`
+
+- knowledge_unit_id:知识单元 ID(与确认后清单一致)。
+- template_type:info | analysis | metric | judgment(四类模板)。
+- overall_logic、chapter_logic、paragraph_position、paragraph_logic:来自大纲与知识单元规则配置。
+- task_input:任务起点业务录入信息(字典,序列化为模板中的「输入信息」块)。
+- data_package:该知识单元数据准备结果(字典,序列化为「相关数据」块)。
+- rag_recall / rag_query / rag_top_k / rag_min_score:为段落生成阶段**自动 RAG** 使用(见上文)。
+- example、notes:示例与补充注意事项(可选)。
+
+────────────────────────────────────────────────────────────────
+三(附)、RAG  —  数据资源类型「RAG」(需求文档配置体系「数据资源管理及配置」)
+────────────────────────────────────────────────────────────────
+与接口、指标平台并列;运维在库中将某知识单元规则的数据源标为 RAG 后,运行端将上传材料
+解析为文本,写入算法侧任务级索引并完成向量检索,结果整理后进入 `SectionRequest.data_package`
+(如 `rag` 字段或拼入「相关数据」),供段落模板消费。
+RAG **HTTP 契约**见 `schemas/rag.py`;底层仅含三子包:`rag/ingestion`、`rag/vectorstore`、`rag/retrieval`;
+编排见 `skills/rag_retrieve/RagService`。
+
+- `POST /v1/rag/ingest`:`task_id` + `documents[]`(`doc_id`、`text` 必填;`source_label`、页码等 metadata 选填);
+  `replace=true` 时覆盖该任务已有索引(与「按 task_id 隔离集合」一致)。
+- `POST /v1/rag/ingest-files`:**multipart** `task_id`、`replace`、**`files[]`**。Python 端自动解析
+  txt/md/csv/json 及 **pdf(pypdf 抽文本;扫描版需先 OCR)** 后调用与 `ingest` 相同入库逻辑。
+  适合「材料一到 Python 就入向量库」的链路。
+- `POST /v1/rag/retrieve`:`task_id` + `query` + `top_k` / `min_score`(可选);返回 `hits` 与 `formatted_context`。
+  数据准备阶段可由 Java **显式调用**本接口,将 `formatted_context` 写入知识单元数据包。
+- `DELETE /v1/rag/{task_id}`:清空该任务索引(联调/重跑材料时可用)。
+- `POST /v1/section`:请求体可选 **`rag_recall=true`**(且提供 **`task_id`**)。
+  为 true 时在本服务内**先**按 `rag_query`(可空则自动拼接段落定位/撰写逻辑/知识单元 id)
+  调用 RAG **召回**,将结果写入本次生成的 **`data_package["rag_recall"]`** 再渲染模板,
+  实现「生成段落前自动带召回片段」(可与 Java 侧数据准备二选一或组合:若已手工填入 `data_package` 仍可再并入)。
+
+环境变量:`FINREP_RAG_CHUNK_SIZE`、`FINREP_RAG_CHUNK_OVERLAP`、`FINREP_RAG_DEFAULT_TOP_K`、
+`FINREP_RAG_EMBEDDING_BATCH_SIZE`(可选,见 `Settings`)。
+
+────────────────────────────────────────────────────────────────
+四、与需求文档对象的映射(便于后端落库命名)
+────────────────────────────────────────────────────────────────
+- 一级大纲结果对象 ← L1 请求 + L1 响应(chapter_results、overall_logic、raw 等)。
+- 最终知识单元清单 ← 用户对 L2 树确认后由 Java 持久化(本服务仅产出候选结构草稿)。
+- 知识单元数据包 ← data_package 的来源侧在 Java;section 消费合并后的结果。
+"""

+ 136 - 0
algo/src/finrep_algo_agent/schemas/outline.py

@@ -0,0 +1,136 @@
+from __future__ import annotations
+
+from decimal import Decimal
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+
+class ChapterCandidate(BaseModel):
+    """一级章节候选(预置知识体系 level=1);扩展字段随 JSON 原样写入提示词清单。
+
+    建议在 `chapter_candidates` 中与需求文档一致的扩展键包括(键名由 Java/预置库约定,示例如下):
+    - 重要性(有独立报告) / 重要性(无独立报告)
+    - 适用条件、备注、知识边界说明
+    - 末级段落统计上限(或同级语义字段)
+    """
+
+    model_config = ConfigDict(extra="allow")
+
+    chapter_id: str = ""
+    chapter_name: str = ""
+
+
+class OutlineL1Request(BaseModel):
+    """一级大纲:与 Java 侧对齐的主要字段;其余可放 extra 或后续补全。"""
+
+    task_id: str | None = None
+    tenant_id: str | None = None
+    report_type: str
+    agreement_amount: Decimal | None = None
+    enterprise_type: str | None = None
+    group_business_segments: list[str] = Field(default_factory=list)
+    industry_type: str | None = None
+    has_independent_report: bool | None = None
+    independent_report_types: list[str] = Field(default_factory=list)
+    candidate_financing_tools: list[str] = Field(default_factory=list)
+    recommended_financing_tools: list[str] = Field(default_factory=list)
+    other_requirements: str | None = None
+    chapter_candidates: list[ChapterCandidate] = Field(
+        default_factory=list,
+        description="可选:覆盖提示词模板内嵌的一级候选;为空则模板按 report_type 展示内置 Demo 清单",
+    )
+
+
+class ChapterL1Result(BaseModel):
+    chapter_id: str
+    chapter_name: str
+    presentation_enum: str = Field(
+        description="一级章节呈现方式:S1 独立一级章节呈现,S2 不呈现(需求文档 6.3.1.1)",
+    )
+    paragraph_count_enum: str = Field(description="P0~P4")
+    reason: str = ""
+
+    @field_validator("presentation_enum")
+    @classmethod
+    def _normalize_s(cls, v: object) -> str:
+        s = str(v).strip().upper()
+        if s not in ("S1", "S2"):
+            raise ValueError("presentation_enum 必须为 S1 或 S2")
+        return s
+
+    @field_validator("paragraph_count_enum")
+    @classmethod
+    def _normalize_p(cls, v: object) -> str:
+        s = str(v).strip().upper()
+        if s not in ("P0", "P1", "P2", "P3", "P4"):
+            raise ValueError("paragraph_count_enum 必须为 P0、P1、P2、P3、P4 之一")
+        return s
+
+    @model_validator(mode="after")
+    def _s1_s2_vs_p(self) -> ChapterL1Result:
+        if self.presentation_enum == "S2" and self.paragraph_count_enum != "P0":
+            raise ValueError("呈现方式为 S2(不呈现)时,段落数量必须为 P0")
+        if self.presentation_enum == "S1" and self.paragraph_count_enum == "P0":
+            raise ValueError("呈现方式为 S1 时,段落数量不可为 P0")
+        return self
+
+
+class OutlineL1Response(BaseModel):
+    chapter_results: list[ChapterL1Result]
+    overall_logic: str = ""
+
+
+class OutlineL2Request(BaseModel):
+    task_id: str | None = None
+    tenant_id: str | None = None
+    chapter_name: str
+    chapter_no: str
+    l1_chapter_id: str | None = Field(
+        default=None,
+        description="L1 结果中该章 chapter_id,用于提示词模板内分支匹配末级 Demo 清单;不传则走该 report_type 的通用末级分支",
+    )
+    chapter_paragraph_count_enum: str | None = None
+    chapter_presentation_enum: str | None = Field(
+        default=None,
+        description="L1 该章 presentation_enum(S1/S2),对应需求文档 6.3.2「呈现方式」",
+    )
+    chapter_reason: str = ""
+    overall_logic: str = ""
+    leaf_chapter_candidates: list[dict[str, Any]] = Field(
+        default_factory=list,
+        description="可选:覆盖提示词模板内嵌的末级候选;为空则模板按 report_type + l1_chapter_id 分支展示",
+    )
+    l1_task_snapshot: OutlineL1Request | None = Field(
+        default=None,
+        description="可选:整段 OutlineL1Request 快照,优先取 report_type 等拼背景",
+    )
+    report_type: str | None = None
+    agreement_amount: Decimal | None = None
+    enterprise_type: str | None = None
+    group_business_segments: list[str] = Field(default_factory=list)
+    industry_type: str | None = None
+    has_independent_report: bool | None = None
+    independent_report_types: list[str] = Field(default_factory=list)
+    candidate_financing_tools: list[str] = Field(default_factory=list)
+    recommended_financing_tools: list[str] = Field(default_factory=list)
+    other_requirements: str | None = None
+    l1_context: dict[str, Any] | None = None
+
+
+class ChapterStructureNode(BaseModel):
+    node_id: str
+    node_name: str
+    node_no: str
+    node_level: int
+    parent_node_id: str | None = None
+    source_type: str | None = None
+    source_candidate_name: str | None = None
+    is_selected: bool = True
+
+
+class OutlineL2Response(BaseModel):
+    chapter_name: str
+    chapter_no: str
+    chapter_structure: list[ChapterStructureNode]
+    structure_logic: str = ""

+ 78 - 0
algo/src/finrep_algo_agent/schemas/rag.py

@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+
+class RagDocumentIn(BaseModel):
+    doc_id: str = Field(description="文档唯一标识(任务内唯一即可)")
+    title: str = ""
+    text: str = Field(description="全文或节选纯文本")
+    source_label: str = Field(
+        default="",
+        description="来源展示名,如原始文件名",
+    )
+    page_start: int | None = None
+    page_end: int | None = None
+
+
+class RagIngestRequest(BaseModel):
+    task_id: str
+    tenant_id: str | None = None
+    documents: list[RagDocumentIn] = Field(min_length=1)
+    replace: bool = Field(default=True)
+
+
+class RagIngestResponse(BaseModel):
+    task_id: str
+    document_count: int
+    chunk_count: int
+
+
+class RagRetrieveRequest(BaseModel):
+    task_id: str
+    tenant_id: str | None = None
+    query: str = Field(min_length=1)
+    top_k: int | None = None
+    min_score: float | None = None
+
+
+class RagHit(BaseModel):
+    chunk_id: str
+    text: str
+    score: float
+    doc_id: str
+    title: str = ""
+    source_label: str = ""
+    chunk_index: int = 0
+    page_start: int | None = None
+    page_end: int | None = None
+    extra: dict[str, Any] = Field(default_factory=dict)
+
+
+class RagRetrieveResponse(BaseModel):
+    hits: list[RagHit]
+    formatted_context: str = Field(
+        description="已按片段编排的纯文本,便于嵌入段落生成「相关数据」块",
+    )
+
+
+class RagDeleteResponse(BaseModel):
+    task_id: str
+    deleted: bool
+
+
+class RagFileProcessResult(BaseModel):
+    filename: str
+    doc_id: str
+    characters: int = 0
+    skipped: bool = False
+    warning: str | None = None
+
+
+class RagIngestFilesResponse(BaseModel):
+    task_id: str
+    document_count: int
+    chunk_count: int
+    files: list[RagFileProcessResult] = Field(default_factory=list)

+ 41 - 0
algo/src/finrep_algo_agent/schemas/section.py

@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+
+class SectionRequest(BaseModel):
+    task_id: str | None = None
+    tenant_id: str | None = None
+    knowledge_unit_id: str
+    report_type: str | None = None
+    template_type: str = Field(description="info|analysis|metric|judgment")
+    paragraph_logic: str = ""
+    paragraph_position: str = ""
+    overall_logic: str = ""
+    chapter_logic: str = ""
+    task_input: dict[str, Any] = Field(default_factory=dict)
+    data_package: dict[str, Any] = Field(default_factory=dict)
+    example: str = ""
+    notes: str = ""
+    rag_recall: bool = Field(
+        default=False,
+        description="为 true 且提供 task_id 时,在调用模型前自动从本任务 RAG 索引召回,写入 data_package['rag_recall']",
+    )
+    rag_query: str | None = Field(
+        default=None,
+        description="召回查询语句;为空则用 paragraph_position + paragraph_logic + knowledge_unit_id 拼接",
+    )
+    rag_top_k: int | None = None
+    rag_min_score: float | None = None
+
+
+class TokenUsage(BaseModel):
+    prompt_tokens: int = 0
+    completion_tokens: int = 0
+
+
+class SectionResponse(BaseModel):
+    generated_text: str
+    usage: TokenUsage = Field(default_factory=TokenUsage)

+ 6 - 0
algo/src/finrep_algo_agent/skills/__init__.py

@@ -0,0 +1,6 @@
+from finrep_algo_agent.skills.outline_l1 import run_outline_l1
+from finrep_algo_agent.skills.outline_l2 import run_outline_l2
+from finrep_algo_agent.skills.rag_retrieve import RagService
+from finrep_algo_agent.skills.section_gen import run_section
+
+__all__ = ["run_outline_l1", "run_outline_l2", "run_section", "RagService"]

二進制
algo/src/finrep_algo_agent/skills/__pycache__/__init__.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/skills/__pycache__/outline_l1.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/skills/__pycache__/outline_l2.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/skills/__pycache__/section_gen.cpython-312.pyc


+ 0 - 0
algo/src/finrep_algo_agent/skills/outline_l1/.gitkeep


+ 3 - 0
algo/src/finrep_algo_agent/skills/outline_l1/__init__.py

@@ -0,0 +1,3 @@
+from finrep_algo_agent.skills.outline_l1.outline_l1 import run_outline_l1
+
+__all__ = ["run_outline_l1"]

二進制
algo/src/finrep_algo_agent/skills/outline_l1/__pycache__/__init__.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/skills/outline_l1/__pycache__/outline_l1.cpython-312.pyc


+ 78 - 0
algo/src/finrep_algo_agent/skills/outline_l1/outline_l1.py

@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+import logging
+
+from finrep_algo_agent.config import Settings
+from finrep_algo_agent.llm import LlmClient
+from finrep_algo_agent.prompts import build_outline_l1_user_prompt
+from finrep_algo_agent.schemas.outline import (
+    ChapterCandidate,
+    ChapterL1Result,
+    OutlineL1Request,
+    OutlineL1Response,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _stub_l1(req: OutlineL1Request) -> OutlineL1Response:
+    results: list[ChapterL1Result] = []
+    candidates: list[ChapterCandidate] = req.chapter_candidates or [
+        ChapterCandidate(chapter_id="stub-1", chapter_name="占位章节"),
+    ]
+    for i, c in enumerate(candidates):
+        cid = c.chapter_id or f"ch-{i+1}"
+        cname = c.chapter_name or f"章节{i+1}"
+        results.append(
+            ChapterL1Result(
+                chapter_id=str(cid),
+                chapter_name=str(cname),
+                presentation_enum="S1",
+                paragraph_count_enum="P2",
+                reason="stub:占位理由",
+            )
+        )
+    return OutlineL1Response(
+        chapter_results=results,
+        overall_logic="stub:本响应为占位数据(FINREP_STUB_SKILLS=true)。",
+    )
+
+
+def _validate_l1_against_request(req: OutlineL1Request, resp: OutlineL1Response) -> None:
+    cands = req.chapter_candidates or []
+    if not cands:
+        return
+    if len(resp.chapter_results) != len(cands):
+        raise ValueError(
+            f"chapter_results 数量({len(resp.chapter_results)})必须与候选章节数量({len(cands)})一致"
+        )
+    for got, exp in zip(resp.chapter_results, cands, strict=True):
+        if got.chapter_id != exp.chapter_id or got.chapter_name != exp.chapter_name:
+            raise ValueError(
+                f"chapter_id/chapter_name 必须与输入原样一致:期望 {exp.chapter_id}/{exp.chapter_name},"
+                f"实际 {got.chapter_id}/{got.chapter_name}"
+            )
+
+
+async def run_outline_l1(
+    req: OutlineL1Request,
+    *,
+    settings: Settings,
+    llm: LlmClient,
+) -> OutlineL1Response:
+    if settings.stub_skills:
+        return _stub_l1(req)
+
+    user_content = build_outline_l1_user_prompt(req)
+    raw = await llm.chat_completion(
+        [{"role": "user", "content": user_content}],
+        temperature=0.2,
+        response_format={"type": "json_object"},
+    )
+    try:
+        out = OutlineL1Response.model_validate_json(raw)
+        _validate_l1_against_request(req, out)
+        return out
+    except Exception as e:
+        logger.exception("L1 JSON parse/validate failed")
+        raise ValueError(f"Invalid L1 LLM output: {e}") from e

+ 0 - 0
algo/src/finrep_algo_agent/skills/outline_l2/.gitkeep


+ 3 - 0
algo/src/finrep_algo_agent/skills/outline_l2/__init__.py

@@ -0,0 +1,3 @@
+from finrep_algo_agent.skills.outline_l2.outline_l2 import run_outline_l2
+
+__all__ = ["run_outline_l2"]

二進制
algo/src/finrep_algo_agent/skills/outline_l2/__pycache__/__init__.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/skills/outline_l2/__pycache__/outline_l2.cpython-312.pyc


+ 77 - 0
algo/src/finrep_algo_agent/skills/outline_l2/outline_l2.py

@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+import logging
+
+from finrep_algo_agent.config import Settings
+from finrep_algo_agent.llm import LlmClient
+from finrep_algo_agent.prompts import build_outline_l2_user_prompt
+from finrep_algo_agent.schemas.outline import (
+    ChapterStructureNode,
+    OutlineL2Request,
+    OutlineL2Response,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _stub_l2(req: OutlineL2Request) -> OutlineL2Response:
+    base_id = f"{req.chapter_no}-ku1"
+    nodes = [
+        ChapterStructureNode(
+            node_id=base_id,
+            node_name=f"{req.chapter_name}-知识单元(占位)",
+            node_no=f"{req.chapter_no}.1",
+            node_level=2,
+            parent_node_id=None,
+            source_type="single",
+            source_candidate_name=None,
+            is_selected=True,
+        ),
+    ]
+    return OutlineL2Response(
+        chapter_name=req.chapter_name,
+        chapter_no=req.chapter_no,
+        chapter_structure=nodes,
+        structure_logic="stub:末级知识单元占位(FINREP_STUB_SKILLS=true)。",
+    )
+
+
+async def run_outline_l2(
+    req: OutlineL2Request,
+    *,
+    settings: Settings,
+    llm: LlmClient,
+) -> OutlineL2Response:
+    if settings.stub_skills:
+        out = _stub_l2(req)
+        mode = (req.chapter_presentation_enum or "").strip().upper()
+        if mode == "S2":
+            out = out.model_copy(
+                update={
+                    "chapter_structure": [],
+                    "structure_logic": "S2:该一级章节不呈现,末级结构置空(stub)。",
+                }
+            )
+        return out
+
+    user_content = build_outline_l2_user_prompt(req)
+    raw = await llm.chat_completion(
+        [{"role": "user", "content": user_content}],
+        temperature=0.2,
+        response_format={"type": "json_object"},
+    )
+    try:
+        parsed = OutlineL2Response.model_validate_json(raw)
+        if parsed.chapter_name != req.chapter_name or parsed.chapter_no != req.chapter_no:
+            raise ValueError(
+                "模型返回的 chapter_name/chapter_no 与请求不一致,"
+                f"期望 {req.chapter_name}/{req.chapter_no},"
+                f"实际 {parsed.chapter_name}/{parsed.chapter_no}"
+            )
+        mode = (req.chapter_presentation_enum or "").strip().upper()
+        if mode == "S2" and parsed.chapter_structure:
+            raise ValueError("chapter_presentation_enum 为 S2 时,chapter_structure 必须为空")
+        return parsed
+    except Exception as e:
+        logger.exception("L2 JSON parse/validate failed")
+        raise ValueError(f"Invalid L2 LLM output: {e}") from e

+ 3 - 0
algo/src/finrep_algo_agent/skills/rag_retrieve/__init__.py

@@ -0,0 +1,3 @@
+from finrep_algo_agent.skills.rag_retrieve.rag_service import RagService
+
+__all__ = ["RagService"]

二進制
algo/src/finrep_algo_agent/skills/rag_retrieve/__pycache__/__init__.cpython-312.pyc


二進制
algo/src/finrep_algo_agent/skills/rag_retrieve/__pycache__/rag_service.cpython-312.pyc


+ 145 - 0
algo/src/finrep_algo_agent/skills/rag_retrieve/rag_service.py

@@ -0,0 +1,145 @@
+"""RAG 编排:入库、检索、删索引(底层实现见 `rag.ingestion` / `rag.vectorstore` / `rag.retrieval`)。"""
+
+from __future__ import annotations
+
+import logging
+import uuid
+
+from finrep_algo_agent.config import Settings
+from finrep_algo_agent.llm import LlmClient
+from finrep_algo_agent.rag.ingestion import chunk_text
+from finrep_algo_agent.rag.retrieval import format_hits_as_context, cosine_similarity
+from finrep_algo_agent.rag.vectorstore import InMemoryRagStore, RagChunkRecord
+from finrep_algo_agent.schemas.rag import (
+    RagDocumentIn,
+    RagHit,
+    RagIngestResponse,
+    RagRetrieveResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class RagService:
+    def __init__(
+        self,
+        *,
+        settings: Settings,
+        llm: LlmClient,
+        store: InMemoryRagStore,
+    ) -> None:
+        self._settings = settings
+        self._llm = llm
+        self._store = store
+
+    def delete_index(self, task_id: str) -> bool:
+        existing = self._store.list_task_chunks(task_id)
+        self._store.clear_task(task_id)
+        return len(existing) > 0
+
+    async def ingest(
+        self,
+        task_id: str,
+        documents: list[RagDocumentIn],
+        *,
+        replace: bool,
+    ) -> RagIngestResponse:
+        records: list[RagChunkRecord] = []
+        chunk_size = self._settings.rag_chunk_size
+        overlap = self._settings.rag_chunk_overlap
+        batch = max(1, self._settings.rag_embedding_batch_size)
+
+        for doc in documents:
+            pieces = chunk_text(doc.text, chunk_size=chunk_size, overlap=overlap)
+            for idx, piece in enumerate(pieces):
+                chunk_id = f"{task_id}:{doc.doc_id}:{idx}:{uuid.uuid4().hex[:8]}"
+                records.append(
+                    RagChunkRecord(
+                        chunk_id=chunk_id,
+                        task_id=task_id,
+                        doc_id=doc.doc_id,
+                        title=doc.title,
+                        source_label=doc.source_label,
+                        chunk_index=idx,
+                        text=piece,
+                        vector=[],
+                        page_start=doc.page_start,
+                        page_end=doc.page_end,
+                    )
+                )
+
+        if not records:
+            if replace:
+                self._store.replace_chunks(task_id, [])
+            return RagIngestResponse(task_id=task_id, document_count=len(documents), chunk_count=0)
+
+        texts = [r.text for r in records]
+        all_vectors: list[list[float]] = []
+        for i in range(0, len(texts), batch):
+            batch_texts = texts[i : i + batch]
+            try:
+                vecs = await self._llm.embeddings(batch_texts)
+            except Exception:
+                logger.exception("RAG embedding batch failed at offset %s", i)
+                raise
+            all_vectors.extend(vecs)
+        for r, vec in zip(records, all_vectors, strict=True):
+            r.vector = vec
+
+        if replace:
+            self._store.replace_chunks(task_id, records)
+        else:
+            self._store.append_chunks(task_id, records)
+
+        return RagIngestResponse(
+            task_id=task_id,
+            document_count=len(documents),
+            chunk_count=len(records),
+        )
+
+    async def retrieve(
+        self,
+        task_id: str,
+        query: str,
+        *,
+        top_k: int | None,
+        min_score: float | None,
+    ) -> RagRetrieveResponse:
+        k = top_k if top_k is not None else self._settings.rag_default_top_k
+        k = max(1, k)
+        chunks = self._store.list_task_chunks(task_id)
+        if not chunks:
+            return RagRetrieveResponse(hits=[], formatted_context=format_hits_as_context([]))
+
+        q_vec = await self._llm.embedding(query.strip())
+        scored: list[tuple[float, RagChunkRecord]] = []
+        for ch in chunks:
+            try:
+                s = cosine_similarity(q_vec, ch.vector)
+            except ValueError:
+                continue
+            scored.append((s, ch))
+        scored.sort(key=lambda x: x[0], reverse=True)
+
+        hits: list[RagHit] = []
+        for score, ch in scored[:k]:
+            if min_score is not None and score < min_score:
+                continue
+            hits.append(
+                RagHit(
+                    chunk_id=ch.chunk_id,
+                    text=ch.text,
+                    score=score,
+                    doc_id=ch.doc_id,
+                    title=ch.title,
+                    source_label=ch.source_label,
+                    chunk_index=ch.chunk_index,
+                    page_start=ch.page_start,
+                    page_end=ch.page_end,
+                    extra={},
+                )
+            )
+        return RagRetrieveResponse(
+            hits=hits,
+            formatted_context=format_hits_as_context(hits),
+        )

+ 0 - 0
algo/src/finrep_algo_agent/skills/section_gen/.gitkeep


+ 3 - 0
algo/src/finrep_algo_agent/skills/section_gen/__init__.py

@@ -0,0 +1,3 @@
+from finrep_algo_agent.skills.section_gen.section_gen import run_section
+
+__all__ = ["run_section"]

二進制
algo/src/finrep_algo_agent/skills/section_gen/__pycache__/__init__.cpython-312.pyc


部分文件因文件數量過多而無法顯示