2643616413 12 часов назад
Родитель
Сommit
75fdcd7fa1
92 измененных файлов с 2006 добавлено и 374 удалено
  1. 3 1
      .gitignore
  2. 23 0
      algo/.env
  3. 31 3
      algo/README.md
  4. 2 2
      algo/deploy/Dockerfile
  5. 22 4
      algo/env.example
  6. 2 0
      algo/pyproject.toml
  7. 41 3
      algo/src/finrep_algo_agent.egg-info/PKG-INFO
  8. 25 5
      algo/src/finrep_algo_agent.egg-info/SOURCES.txt
  9. 2 0
      algo/src/finrep_algo_agent.egg-info/requires.txt
  10. BIN
      algo/src/finrep_algo_agent/__pycache__/main.cpython-312.pyc
  11. BIN
      algo/src/finrep_algo_agent/api/__pycache__/deps.cpython-312.pyc
  12. 19 0
      algo/src/finrep_algo_agent/api/deps.py
  13. 3 1
      algo/src/finrep_algo_agent/api/routers/__init__.py
  14. BIN
      algo/src/finrep_algo_agent/api/routers/__pycache__/__init__.cpython-312.pyc
  15. BIN
      algo/src/finrep_algo_agent/api/routers/__pycache__/debug.cpython-312.pyc
  16. BIN
      algo/src/finrep_algo_agent/api/routers/__pycache__/rag.cpython-312.pyc
  17. BIN
      algo/src/finrep_algo_agent/api/routers/__pycache__/section.cpython-312.pyc
  18. 68 0
      algo/src/finrep_algo_agent/api/routers/debug.py
  19. 130 0
      algo/src/finrep_algo_agent/api/routers/rag.py
  20. 8 3
      algo/src/finrep_algo_agent/api/routers/section.py
  21. BIN
      algo/src/finrep_algo_agent/config/__pycache__/settings.cpython-312.pyc
  22. 24 2
      algo/src/finrep_algo_agent/config/settings.py
  23. 1 1
      algo/src/finrep_algo_agent/llm/__init__.py
  24. BIN
      algo/src/finrep_algo_agent/llm/__pycache__/__init__.cpython-312.pyc
  25. BIN
      algo/src/finrep_algo_agent/llm/__pycache__/client.cpython-312.pyc
  26. 0 68
      algo/src/finrep_algo_agent/llm/client.py
  27. 0 0
      algo/src/finrep_algo_agent/llm/client/.gitkeep
  28. BIN
      algo/src/finrep_algo_agent/llm/client/__pycache__/client.cpython-312.pyc
  29. 163 0
      algo/src/finrep_algo_agent/llm/client/client.py
  30. 23 1
      algo/src/finrep_algo_agent/main.py
  31. BIN
      algo/src/finrep_algo_agent/prompts/__pycache__/builders.cpython-312.pyc
  32. 44 12
      algo/src/finrep_algo_agent/prompts/builders.py
  33. 185 99
      algo/src/finrep_algo_agent/prompts/templates/outline_l1.j2
  34. 297 120
      algo/src/finrep_algo_agent/prompts/templates/outline_l2.j2
  35. 1 0
      algo/src/finrep_algo_agent/rag/__init__.py
  36. BIN
      algo/src/finrep_algo_agent/rag/__pycache__/__init__.cpython-312.pyc
  37. 4 0
      algo/src/finrep_algo_agent/rag/ingestion/__init__.py
  38. BIN
      algo/src/finrep_algo_agent/rag/ingestion/__pycache__/__init__.cpython-312.pyc
  39. BIN
      algo/src/finrep_algo_agent/rag/ingestion/__pycache__/chunking.cpython-312.pyc
  40. BIN
      algo/src/finrep_algo_agent/rag/ingestion/__pycache__/file_extract.cpython-312.pyc
  41. 29 0
      algo/src/finrep_algo_agent/rag/ingestion/chunking.py
  42. 57 0
      algo/src/finrep_algo_agent/rag/ingestion/file_extract.py
  43. 4 0
      algo/src/finrep_algo_agent/rag/retrieval/__init__.py
  44. BIN
      algo/src/finrep_algo_agent/rag/retrieval/__pycache__/__init__.cpython-312.pyc
  45. BIN
      algo/src/finrep_algo_agent/rag/retrieval/__pycache__/formatting.cpython-312.pyc
  46. BIN
      algo/src/finrep_algo_agent/rag/retrieval/__pycache__/similarity.cpython-312.pyc
  47. 21 0
      algo/src/finrep_algo_agent/rag/retrieval/formatting.py
  48. 18 0
      algo/src/finrep_algo_agent/rag/retrieval/similarity.py
  49. 3 0
      algo/src/finrep_algo_agent/rag/vectorstore/__init__.py
  50. BIN
      algo/src/finrep_algo_agent/rag/vectorstore/__pycache__/__init__.cpython-312.pyc
  51. BIN
      algo/src/finrep_algo_agent/rag/vectorstore/__pycache__/store.cpython-312.pyc
  52. 42 0
      algo/src/finrep_algo_agent/rag/vectorstore/store.py
  53. 20 0
      algo/src/finrep_algo_agent/schemas/__init__.py
  54. BIN
      algo/src/finrep_algo_agent/schemas/__pycache__/__init__.cpython-312.pyc
  55. BIN
      algo/src/finrep_algo_agent/schemas/__pycache__/outline.cpython-312.pyc
  56. BIN
      algo/src/finrep_algo_agent/schemas/__pycache__/rag.cpython-312.pyc
  57. BIN
      algo/src/finrep_algo_agent/schemas/__pycache__/section.cpython-312.pyc
  58. 90 0
      algo/src/finrep_algo_agent/schemas/contracts.py
  59. 55 8
      algo/src/finrep_algo_agent/schemas/outline.py
  60. 78 0
      algo/src/finrep_algo_agent/schemas/rag.py
  61. 10 0
      algo/src/finrep_algo_agent/schemas/section.py
  62. 2 1
      algo/src/finrep_algo_agent/skills/__init__.py
  63. BIN
      algo/src/finrep_algo_agent/skills/__pycache__/__init__.cpython-312.pyc
  64. 0 0
      algo/src/finrep_algo_agent/skills/outline_l1/.gitkeep
  65. 3 0
      algo/src/finrep_algo_agent/skills/outline_l1/__init__.py
  66. BIN
      algo/src/finrep_algo_agent/skills/outline_l1/__pycache__/__init__.cpython-312.pyc
  67. BIN
      algo/src/finrep_algo_agent/skills/outline_l1/__pycache__/outline_l1.cpython-312.pyc
  68. 3 0
      algo/src/finrep_algo_agent/skills/outline_l1/outline_l1.py
  69. 0 0
      algo/src/finrep_algo_agent/skills/outline_l2/.gitkeep
  70. 3 0
      algo/src/finrep_algo_agent/skills/outline_l2/__init__.py
  71. BIN
      algo/src/finrep_algo_agent/skills/outline_l2/__pycache__/__init__.cpython-312.pyc
  72. BIN
      algo/src/finrep_algo_agent/skills/outline_l2/__pycache__/outline_l2.cpython-312.pyc
  73. 13 1
      algo/src/finrep_algo_agent/skills/outline_l2/outline_l2.py
  74. 3 0
      algo/src/finrep_algo_agent/skills/rag_retrieve/__init__.py
  75. BIN
      algo/src/finrep_algo_agent/skills/rag_retrieve/__pycache__/__init__.cpython-312.pyc
  76. BIN
      algo/src/finrep_algo_agent/skills/rag_retrieve/__pycache__/rag_service.cpython-312.pyc
  77. 145 0
      algo/src/finrep_algo_agent/skills/rag_retrieve/rag_service.py
  78. 0 39
      algo/src/finrep_algo_agent/skills/section_gen.py
  79. 0 0
      algo/src/finrep_algo_agent/skills/section_gen/.gitkeep
  80. 3 0
      algo/src/finrep_algo_agent/skills/section_gen/__init__.py
  81. BIN
      algo/src/finrep_algo_agent/skills/section_gen/__pycache__/__init__.cpython-312.pyc
  82. BIN
      algo/src/finrep_algo_agent/skills/section_gen/__pycache__/section_gen.cpython-312.pyc
  83. 82 0
      algo/src/finrep_algo_agent/skills/section_gen/section_gen.py
  84. BIN
      algo/tests/__pycache__/test_health.cpython-312-pytest-9.0.2.pyc
  85. BIN
      algo/tests/__pycache__/test_prompts.cpython-312-pytest-9.0.2.pyc
  86. BIN
      algo/tests/__pycache__/test_rag.cpython-312-pytest-9.0.2.pyc
  87. BIN
      algo/tests/__pycache__/test_section_rag.cpython-312-pytest-9.0.2.pyc
  88. 22 0
      algo/tests/test_health.py
  89. 54 0
      algo/tests/test_prompts.py
  90. 76 0
      algo/tests/test_rag.py
  91. 49 0
      algo/tests/test_section_rag.py
  92. BIN
      docs/财顾报告智能化生成产品MVP版本需求文档.pdf

+ 3 - 1
.gitignore

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

+ 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=

+ 31 - 3
algo/README.md

@@ -9,7 +9,7 @@ cd algo
 python -m venv .venv
 python -m venv .venv
 .\.venv\Scripts\activate
 .\.venv\Scripts\activate
 pip install -e ".[dev]"
 pip install -e ".[dev]"
-uvicorn finrep_algo_agent.main:app --reload --host 0.0.0.0 --port 8001 --app-dir src
+uvicorn finrep_algo_agent.main:app --reload --host 0.0.0.0 --port 8002 --app-dir src
 ```
 ```
 
 
 ## 环境变量
 ## 环境变量
@@ -21,10 +21,38 @@ uvicorn finrep_algo_agent.main:app --reload --host 0.0.0.0 --port 8001 --app-dir
 - `FINREP_LLM_MODEL`:模型名
 - `FINREP_LLM_MODEL`:模型名
 - `FINREP_STUB_SKILLS=true`:为 `true` 时 L1/L2/Section 返回固定占位数据,不调模型
 - `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:8001/health`
-- `POST http://localhost:8001/v1/outline/l1`(JSON 见 `schemas`)
+- `GET http://localhost:8002/health`
+- `POST http://localhost:8002/v1/outline/l1`(JSON 见 `schemas`)
 
 
 ## 与《MVP 需求文档》对齐的交互字段
 ## 与《MVP 需求文档》对齐的交互字段
 
 

+ 2 - 2
algo/deploy/Dockerfile

@@ -12,5 +12,5 @@ COPY pyproject.toml README.md ./
 COPY src ./src
 COPY src ./src
 RUN pip install .
 RUN pip install .
 
 
-EXPOSE 8001
-CMD ["uvicorn", "finrep_algo_agent.main:app", "--host", "0.0.0.0", "--port", "8001", "--app-dir", "src"]
+EXPOSE 8002
+CMD ["uvicorn", "finrep_algo_agent.main:app", "--host", "0.0.0.0", "--port", "8002", "--app-dir", "src"]

+ 22 - 4
algo/env.example

@@ -1,11 +1,29 @@
-# OpenAI 兼容接口
-FINREP_LLM_BASE_URL=https://api.openai.com/v1
-FINREP_LLM_API_KEY=
-FINREP_LLM_MODEL=gpt-4o-mini
+# 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
 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 时可先开)
 # true:L1/L2/Section 不调模型,返回占位 JSON(联调 Java 时可先开)
 FINREP_STUB_SKILLS=true
 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 对齐)
 # 可选:服务间校验(占位,后续与 Java 对齐)
 FINREP_SERVICE_TOKEN=
 FINREP_SERVICE_TOKEN=

+ 2 - 0
algo/pyproject.toml

@@ -15,6 +15,8 @@ dependencies = [
     "pydantic-settings>=2",
     "pydantic-settings>=2",
     "httpx>=0.27",
     "httpx>=0.27",
     "jinja2>=3.1",
     "jinja2>=3.1",
+    "python-multipart>=0.0.9",
+    "pypdf>=4.0",
 ]
 ]
 
 
 [project.optional-dependencies]
 [project.optional-dependencies]

+ 41 - 3
algo/src/finrep_algo_agent.egg-info/PKG-INFO

@@ -10,6 +10,8 @@ Requires-Dist: pydantic>=2
 Requires-Dist: pydantic-settings>=2
 Requires-Dist: pydantic-settings>=2
 Requires-Dist: httpx>=0.27
 Requires-Dist: httpx>=0.27
 Requires-Dist: jinja2>=3.1
 Requires-Dist: jinja2>=3.1
+Requires-Dist: python-multipart>=0.0.9
+Requires-Dist: pypdf>=4.0
 Provides-Extra: dev
 Provides-Extra: dev
 Requires-Dist: pytest>=8; extra == "dev"
 Requires-Dist: pytest>=8; extra == "dev"
 Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
 Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
@@ -25,7 +27,7 @@ cd algo
 python -m venv .venv
 python -m venv .venv
 .\.venv\Scripts\activate
 .\.venv\Scripts\activate
 pip install -e ".[dev]"
 pip install -e ".[dev]"
-uvicorn finrep_algo_agent.main:app --reload --host 0.0.0.0 --port 8001 --app-dir src
+uvicorn finrep_algo_agent.main:app --reload --host 0.0.0.0 --port 8002 --app-dir src
 ```
 ```
 
 
 ## 环境变量
 ## 环境变量
@@ -37,7 +39,43 @@ uvicorn finrep_algo_agent.main:app --reload --host 0.0.0.0 --port 8001 --app-dir
 - `FINREP_LLM_MODEL`:模型名
 - `FINREP_LLM_MODEL`:模型名
 - `FINREP_STUB_SKILLS=true`:为 `true` 时 L1/L2/Section 返回固定占位数据,不调模型
 - `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:8001/health`
-- `POST http://localhost:8001/v1/outline/l1`(JSON 见 `schemas`)
+- `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`,与需求文档摘录保持一致,后续仅以改模板版本迭代。

+ 25 - 5
algo/src/finrep_algo_agent.egg-info/SOURCES.txt

@@ -10,13 +10,15 @@ src/finrep_algo_agent.egg-info/top_level.txt
 src/finrep_algo_agent/api/__init__.py
 src/finrep_algo_agent/api/__init__.py
 src/finrep_algo_agent/api/deps.py
 src/finrep_algo_agent/api/deps.py
 src/finrep_algo_agent/api/routers/__init__.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/health.py
 src/finrep_algo_agent/api/routers/outline.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/api/routers/section.py
 src/finrep_algo_agent/config/__init__.py
 src/finrep_algo_agent/config/__init__.py
 src/finrep_algo_agent/config/settings.py
 src/finrep_algo_agent/config/settings.py
 src/finrep_algo_agent/llm/__init__.py
 src/finrep_algo_agent/llm/__init__.py
-src/finrep_algo_agent/llm/client.py
+src/finrep_algo_agent/llm/client/client.py
 src/finrep_algo_agent/prompts/__init__.py
 src/finrep_algo_agent/prompts/__init__.py
 src/finrep_algo_agent/prompts/builders.py
 src/finrep_algo_agent/prompts/builders.py
 src/finrep_algo_agent/prompts/templates/outline_l1.j2
 src/finrep_algo_agent/prompts/templates/outline_l1.j2
@@ -25,12 +27,30 @@ 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_info.j2
 src/finrep_algo_agent/prompts/templates/section_judgment.j2
 src/finrep_algo_agent/prompts/templates/section_judgment.j2
 src/finrep_algo_agent/prompts/templates/section_metric.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/__init__.py
+src/finrep_algo_agent/schemas/contracts.py
 src/finrep_algo_agent/schemas/outline.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/schemas/section.py
 src/finrep_algo_agent/skills/__init__.py
 src/finrep_algo_agent/skills/__init__.py
-src/finrep_algo_agent/skills/outline_l1.py
-src/finrep_algo_agent/skills/outline_l2.py
-src/finrep_algo_agent/skills/section_gen.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_health.py
-tests/test_prompts.py
+tests/test_prompts.py
+tests/test_rag.py
+tests/test_section_rag.py

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

@@ -4,6 +4,8 @@ pydantic>=2
 pydantic-settings>=2
 pydantic-settings>=2
 httpx>=0.27
 httpx>=0.27
 jinja2>=3.1
 jinja2>=3.1
+python-multipart>=0.0.9
+pypdf>=4.0
 
 
 [dev]
 [dev]
 pytest>=8
 pytest>=8

BIN
algo/src/finrep_algo_agent/__pycache__/main.cpython-312.pyc


BIN
algo/src/finrep_algo_agent/api/__pycache__/deps.cpython-312.pyc


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

@@ -6,11 +6,30 @@ from fastapi import Depends
 
 
 from finrep_algo_agent.config import Settings, get_settings
 from finrep_algo_agent.config import Settings, get_settings
 from finrep_algo_agent.llm import LlmClient
 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:
 def get_llm(settings: Annotated[Settings, Depends(get_settings)]) -> LlmClient:
     return LlmClient(settings)
     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)]
 SettingsDep = Annotated[Settings, Depends(get_settings)]
 LlmDep = Annotated[LlmClient, Depends(get_llm)]
 LlmDep = Annotated[LlmClient, Depends(get_llm)]
+RagServiceDep = Annotated[RagService, Depends(get_rag_service)]

+ 3 - 1
algo/src/finrep_algo_agent/api/routers/__init__.py

@@ -1,5 +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.health import router as health_router
 from finrep_algo_agent.api.routers.outline import router as outline_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
 from finrep_algo_agent.api.routers.section import router as section_router
 
 
-__all__ = ["health_router", "outline_router", "section_router"]
+__all__ = ["health_router", "outline_router", "rag_router", "section_router", "debug_router"]

BIN
algo/src/finrep_algo_agent/api/routers/__pycache__/__init__.cpython-312.pyc


BIN
algo/src/finrep_algo_agent/api/routers/__pycache__/debug.cpython-312.pyc


BIN
algo/src/finrep_algo_agent/api/routers/__pycache__/rag.cpython-312.pyc


BIN
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],
+    }
+

+ 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)

+ 8 - 3
algo/src/finrep_algo_agent/api/routers/section.py

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

BIN
algo/src/finrep_algo_agent/config/__pycache__/settings.cpython-312.pyc


+ 24 - 2
algo/src/finrep_algo_agent/config/settings.py

@@ -12,10 +12,32 @@ class Settings(BaseSettings):
         extra="ignore",
         extra="ignore",
     )
     )
 
 
-    llm_base_url: str = "http://127.0.0.1:9999/v1"
+    # 文本生成模型(默认:阿里云百炼 OpenAI 兼容模式)
+    llm_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
     llm_api_key: str = ""
     llm_api_key: str = ""
-    llm_model: str = "gpt-4o-mini"
+    llm_model: str = "qwen-plus"
     llm_timeout_seconds: float = 120.0
     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)
     # True:技能返回占位 JSON,不请求 LLM(便于先联调 Java)
     stub_skills: bool = Field(default=True)
     stub_skills: bool = Field(default=True)
 
 

+ 1 - 1
algo/src/finrep_algo_agent/llm/__init__.py

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

BIN
algo/src/finrep_algo_agent/llm/__pycache__/__init__.cpython-312.pyc


BIN
algo/src/finrep_algo_agent/llm/__pycache__/client.cpython-312.pyc


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

@@ -1,68 +0,0 @@
-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
-        base = settings.llm_base_url.rstrip("/")
-        if base.endswith("/v1"):
-            self._chat_url = f"{base}/chat/completions"
-        else:
-            self._chat_url = f"{base}/v1/chat/completions"
-
-    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
-
-        headers: dict[str, str] = {"Content-Type": "application/json"}
-        if self._settings.llm_api_key:
-            headers["Authorization"] = f"Bearer {self._settings.llm_api_key}"
-
-        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=headers)
-            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 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

+ 0 - 0
algo/src/finrep_algo_agent/llm/client/.gitkeep


BIN
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

+ 23 - 1
algo/src/finrep_algo_agent/main.py

@@ -1,22 +1,44 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
+from contextlib import asynccontextmanager
 import logging
 import logging
 
 
 from fastapi import FastAPI
 from fastapi import FastAPI
 
 
-from finrep_algo_agent.api.routers import health_router, outline_router, section_router
+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(
 logging.basicConfig(
     level=logging.INFO,
     level=logging.INFO,
     format="%(asctime)s %(levelname)s %(name)s %(message)s",
     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(
 app = FastAPI(
     title="finrep-algo-agent",
     title="finrep-algo-agent",
     version="0.1.0",
     version="0.1.0",
     description="财顾报告 Agent — Python 算法服务",
     description="财顾报告 Agent — Python 算法服务",
+    lifespan=_lifespan,
 )
 )
 
 
 app.include_router(health_router)
 app.include_router(health_router)
+app.include_router(debug_router)
 app.include_router(outline_router, prefix="/v1/outline", tags=["outline"])
 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"])
 app.include_router(section_router, prefix="/v1", tags=["section"])

BIN
algo/src/finrep_algo_agent/prompts/__pycache__/builders.cpython-312.pyc


+ 44 - 12
algo/src/finrep_algo_agent/prompts/builders.py

@@ -41,25 +41,55 @@ def _fmt_decimal(v: Decimal | None) -> str:
     return str(v)
     return str(v)
 
 
 
 
-def format_chapter_candidates_block(req: OutlineL1Request) -> str:
+def _format_kv_block(obj: dict[str, object], indent: str = "  ") -> str:
+    if not obj:
+        return f"{indent}(无扩展字段)"
     lines: list[str] = []
     lines: list[str] = []
-    for i, c in enumerate(req.chapter_candidates or [], start=1):
-        row = c.model_dump(exclude_none=True)
-        lines.append(json.dumps(row, ensure_ascii=False))
-    if not lines:
-        return "(空候选清单)"
-    return "\n".join(f"{i}. {line}" for i, line in enumerate(lines, start=1))
+    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:
 def format_leaf_chapter_candidates_block(leaf: list[dict]) -> str:
+    """仅用于请求体显式传入 `leaf_chapter_candidates` 时的覆盖文本。"""
     if not leaf:
     if not leaf:
-        return "(空末级候选清单)"
-    return json.dumps(leaf, ensure_ascii=False, indent=2)
+        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]:
 def _task_background_dict(req: OutlineL1Request) -> dict[str, str]:
     return {
     return {
-        "report_type": req.report_type,
+        "report_type": (req.report_type or "").strip(),
         "agreement_amount": _fmt_decimal(req.agreement_amount),
         "agreement_amount": _fmt_decimal(req.agreement_amount),
         "enterprise_type": req.enterprise_type or "未提供",
         "enterprise_type": req.enterprise_type or "未提供",
         "group_business_segments": _fmt_list(req.group_business_segments),
         "group_business_segments": _fmt_list(req.group_business_segments),
@@ -74,7 +104,7 @@ def _task_background_dict(req: OutlineL1Request) -> dict[str, str]:
 
 
 def build_outline_l1_user_prompt(req: OutlineL1Request) -> str:
 def build_outline_l1_user_prompt(req: OutlineL1Request) -> str:
     ctx = _task_background_dict(req)
     ctx = _task_background_dict(req)
-    ctx["chapter_candidates_block"] = format_chapter_candidates_block(req)
+    ctx["chapter_candidates_override"] = format_chapter_candidates_block(req)
     return _env().get_template("outline_l1.j2").render(**ctx)
     return _env().get_template("outline_l1.j2").render(**ctx)
 
 
 
 
@@ -102,10 +132,12 @@ def build_outline_l2_user_prompt(req: OutlineL2Request) -> str:
         {
         {
             "chapter_name": req.chapter_name,
             "chapter_name": req.chapter_name,
             "chapter_no": req.chapter_no,
             "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_paragraph_count_enum": req.chapter_paragraph_count_enum or "未提供",
             "chapter_reason": req.chapter_reason or "无",
             "chapter_reason": req.chapter_reason or "无",
             "overall_logic": req.overall_logic or "无",
             "overall_logic": req.overall_logic or "无",
-            "leaf_chapter_candidates_block": format_leaf_chapter_candidates_block(
+            "leaf_chapter_candidates_override": format_leaf_chapter_candidates_block(
                 req.leaf_chapter_candidates
                 req.leaf_chapter_candidates
             ),
             ),
         }
         }

+ 185 - 99
algo/src/finrep_algo_agent/prompts/templates/outline_l1.j2

@@ -1,106 +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)]}
 ###
 ###
-【任务边界】
-你只负责完成一级章节层面的结构判断,包括:
-判断每个一级章节是否呈现
-为每个一级章节分配段落数量枚举值
-输出整篇报告撰写的逻辑说明
-你不得执行以下内容:
-不得撰写任何正文内容
-不得判断二级章节或最末级知识单元
-不得新增、删除、合并、拆分、改写一级章节名称
-不得输出枚举值以外的数量表达
-不得输出 JSON 结构以外的任何内容
-不得展示你的推理过程或计算过程
+【候选 2】
+  chapter_id: pf-l1-02
+  chapter_name: 企业财务情况分析
+  功能性定义:论证融资主体的财务情况。
+  重要性(无独立报告):必定
+  重要性(有独立报告):必定
+  知识边界说明:该章节篇幅跟是否有独立财务分析报告有关,如果存在对应的独立财务分析报告,则在主服务报告中仅需要进行简单的介绍。部分末级章节需要基于输入信息内容判断是否需要撰写。
+  该一级章节下最末级段落数统计:{[无独立报告:(高:9,不撰写:1)],[有独立报告:(必定撰写:1,不撰写:9)]}
 ###
 ###
-【判断规则】
-1. 判断范围约束
-你只能在下方提供的【一级章节候选清单】中进行判断。
-输出结果必须与输入候选章节一一对应,顺序保持一致,不可缺失,不可新增。
-2.段落数量枚举
-你只能使用以下枚举值:
-P0:不呈现
-P1:1段
-P2:2–5段
-P3:5–10段
-P4:10段及以上
-3.协议金额与整体规模约束
-你必须先根据协议金额判断本次报告整体允许的段落规模区间,再进行各一级章节的数量分配。
-协议金额与目标段落区间规则如下:
-<50万 → 10–20段
-50–100万 → 20–30段
-100–500万 → 30–40段
->=500万 → 40–50段
-4.你在内部判断时必须同时考虑:
-协议金额对应的总段落预算范围
-各章节重要性
-是否存在独立专项报告
-章节的额外说明 / 知识边界说明
-章节适用条件
-各章节下可供分配的最末级段落统计上限
-报告结构完整性与论证顺序合理性
-但你不得在输出中展示计算过程。
-5. 重要性选择规则
-你必须根据“是否存在独立报告”来选择适用的重要性字段:
-若存在独立报告,则使用“重要性(有独立报告)”
-若不存在独立报告,则使用“重要性(无独立报告)”
-重要性处理原则如下:
-必定:原则上应保留,除非明确不适用
-高:优先保留,但可在总量约束下压缩篇幅
-中、低:可根据整体结构需要压缩或舍弃
-若额外说明中明确指出“存在独立报告时仅需简单介绍”,则即使保留,也应优先分配较低段落级别
-6. 适用条件规则
-若章节存在适用条件,则必须结合输入背景信息判断。
-若条件不满足,则该章节可判断为不呈现。
-若条件满足,也仍需结合重要性、整体规模约束和结构合理性综合判断。
+【候选 3】
+  chapter_id: pf-l1-03
+  chapter_name: 项目基本情况及融资需求界定
+  功能性定义:界定融资讨论的起点,明确融资对象、融资规模、资金用途与融资动因,是后续分析与方案设计的事实基础
+  重要性(无独立报告):必定
+  重要性(有独立报告):必定
+  知识边界说明:该章节篇幅跟是否有独立项目调查报告有关,如果存在对应的独立项目调查报告,则在主服务报告中仅需要进行简单的介绍。部分末级章节需要基于输入信息内容判断是否需要撰写。
+  该一级章节下最末级段落数统计:{[无独立报告:(必定撰写:3,高:4,不撰写:1)],[有独立报告:(必定撰写:1,不撰写:7)]}
 ###
 ###
-【输入信息】
-1. 报告背景信息
-报告类型:{{ 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 }}
-2. 一级章节候选清单
-{{ chapter_candidates_block }}
+【候选 4】
+  chapter_id: pf-l1-04
+  chapter_name: 融资工具分析
+  功能性定义:在正式方案确定前,对不同融资工具或路径进行并列分析,形成比较与筛选空间
+  重要性(无独立报告):高
+  重要性(有独立报告):高
+  知识边界说明:无
+  该一级章节下最末级段落数统计:{[无独立报告:(必定撰写:6)],[有独立报告:(必定撰写:6)]}
 ###
 ###
-【输出要求】
-你必须仅输出一个合法的 JSON 对象,不得输出任何其他说明文字、标题、注释、Markdown 标记或代码块标记。
-输出 JSON 结构必须严格如下:
+【候选 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",
-"paragraph_count_enum": "P0 or P1 or P2 or P3 or P4",
-"reason": "string"
-}
-],
-"overall_logic": "string"
+  "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_results 中各元素顺序必须与输入候选顺序完全一致
-chapter_id 和 chapter_name 必须与输入内容原样一致,不得改写
-reason 必须是一句简洁自然语言,只说明该章节是否保留及其段落级别的核心原因
-overall_logic 必须是一段自然语言,只说明一级章节的组织顺序、核心与承接关系、整体结构如何服务最终方案论证
-overall_logic 不得出现具体数量计算过程,不得出现 JSON 之外的层级结构
-输出必须是可被程序直接解析的合法 JSON
-###
-【输出字段说明】
-chapter_results:一级章节判断结果列表,按输入候选章节顺序逐项输出
-chapter_id:一级章节唯一标识,必须与输入一致
-chapter_name:一级章节名称,必须与输入一致
-paragraph_count_enum:章节段落数量枚举,P0 表示不呈现,P1 表示1段,P2 表示2–5段,P3 表示5–10段,P4 表示10段及以上
-reason:该章节判断结果的简要原因说明,使用一句自然语言表达
-overall_logic:输出整篇报告撰写的逻辑说明,用一段自然语言说明章节顺序及整体论证关系
-相关页面示例
-备注说明:目前版本提示词模板已剔除“呈现方式”字段
+● chapter_results 长度与顺序必须与【一级章节候选清单】完全一致。
+● chapter_id、chapter_name 必须与输入候选原样一致。
+● reason:一句话说明该章呈现方式与段落档位的核心理由(自然语言一句)。
+● overall_logic:一段连续自然语言,说明各章论证顺序、核心与承接关系、整体结构如何服务融资/交易方案论证;**不得**写具体 P 档位数字与计算过程,**不得**新增章节概念。
+● 所有数量只允许 P0–P4;所有呈现方式只允许 S1–S2。
+● 输出必须是可被程序直接解析的合法 JSON。

+ 297 - 120
algo/src/finrep_algo_agent/prompts/templates/outline_l2.j2

@@ -1,137 +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" %}
 ###
 ###
-【任务边界】
-你只负责当前一级章节下的末级章节结构装配,包括:
-判断哪些候选末级章节需要纳入
-按真实报告逻辑生成章节顺序
-生成可直接用于报告拼接的章节编号结构
-对批次处理型章节按输入对象展开子章节
-输出一段结构构建逻辑说明
-你不得执行以下内容:
-不得撰写任何正文内容
-不得输出解释性分析
-不得输出推理过程
-不得新增候选清单中不存在的知识单元
-不得修改输入中已有章节名称
-不得改变候选清单原有层级
-不得输出 JSON 结构以外的任何内容
+末级章节名称:企业基本情况分析\企业基本情况概述
+  node_id: pf-01-ku-01
+描述信息:针对存在独立企业分析报告的情况下,在主服务报告中进行简单说明
+重要性(无独立报告):不撰写
+重要性(有独立报告):必定
+知识单元类型(技术实现方式):单元呈现型
+备注:无
 ###
 ###
-【核心判断规则】
-1. 上游约束继承规则
-你必须严格继承上游一级大纲结果,仅对当前一级章节进行处理。
-2. P 枚举使用规则
-上游输入中的段落数量枚举值仅作为规模参考,不是严格上限。若为了保证论证完整性、批次展开或结构闭环,需要超出该规模参考,可以适度扩展。你不得在输出中体现任何数量推演过程。
-P枚举值对应段落数量关系:
-P0:不呈现
-P1:1段
-P2:2–5段
-P3:5–10段
-P4:10段及以上
-3. 重要性优先规则
-你必须根据“是否存在独立报告”选择对应的重要性字段:
-若存在独立报告,则使用“重要性(有独立报告)”
-若不存在独立报告,则使用“重要性(无独立报告)”
-判断原则如下:
-必定:必须纳入
-高:原则上纳入,除非条件不满足或与其他内容明显重复
-低:仅在增强论证闭环、满足输入触发条件或对结构完整性有必要时纳入
-不撰写:必须排除
-4. 条件触发规则
-若候选章节存在备注中的适用条件,则必须结合输入背景信息进行判断。
-若条件不满足,则该候选章节不得纳入。
-若条件满足,则结合重要性和整体结构合理性判断是否纳入。5. 批次处理型展开规则
-若候选章节的“知识单元类型”为 批次处理型,则必须按输入对象逐一展开,并生成独立子章节。
-展开规则如下:
-每个输入对象生成一个独立章节节点
-子章节名称使用输入对象名称
-自动补全对应层级编号
-该类展开不受上游 P 枚举的严格限制
-展开后的章节层级必须保持稳定,不得跳级
-例如:若某候选章节要求按“集团板块名称”展开,则你必须基于输入中的所有集团板块名称逐一生成子章节。
-6. 结构完整性优先规则
-若仅按上游规模参考不足以覆盖关键知识单元或无法形成完整论证闭环,则应优先保证结构完整性,并适度扩展章节数量。
-但不得无依据地纳入大量低重要性内容。
+末级章节名称:企业基本情况分析\企业基本情况
+  node_id: pf-01-ku-02
+描述信息:展示说明该企业的基本信息,主要为工商信息
+重要性(无独立报告):必定
+重要性(有独立报告):不撰写
+知识单元类型(技术实现方式):单元呈现型
+备注:无
 ###
 ###
-【章节编号规则】
-你必须生成真实报告可用的章节编号结构。
-编号规则如下:一级章节:1
-二级章节:1.1
-三级章节:1.1.1
-四级章节:1.1.1.1
-你必须遵守以下要求:
-编号必须连续,不得跳号
-顺序必须稳定
-编号层级必须与候选章节原始层级一致
-若为批次处理型展开,可在原有允许层级下生成对应子节点
-不得将低层级内容提升或降级为其他层级
-输出结果必须可直接用于报告拼接
+末级章节名称:企业基本情况分析\企业所属集团情况
+  node_id: pf-01-ku-03
+描述信息:对集团层面不同业务板块的构成及其经营内容进行拆解说明,用于展示集团整体业务布局及对下属企业的支撑背景
+重要性(无独立报告):高
+重要性(有独立报告):不撰写
+知识单元类型(技术实现方式):批次处理类
+备注:若企业类型为「集团企业」,则需要撰写该段落,基于业务上传或录入的板块构成批次处理生成对应段落,并且将每个业务板块的名称作为三级章节的章节标题名称
 ###
 ###
-四、输入信息
-1. 已确定的上游约束
-一级章节名称:{{ chapter_name }}
-一级章节编号:{{ chapter_no }}
-段落数量:{{ chapter_paragraph_count_enum }}
-判断说明:{{ chapter_reason }}
-整体写作逻辑说明:{{ overall_logic }}
-2. 报告背景信息
-报告类型:{{ 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 }}
-3. 最末级章节候选清单
-{{ leaf_chapter_candidates_block }}
+末级章节名称:企业基本情况分析\企业股权结构
+  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 对象,不得输出任何其他说明文字、标题、注释、Markdown 标记或代码块标记。
 输出 JSON 结构必须严格如下:
 输出 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
+  "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"
 }
 }
-],
-"structure_logic": "string"
-}
-###
+######
 【输出字段说明】
 【输出字段说明】
 chapter_name:当前一级章节名称,与输入一致
 chapter_name:当前一级章节名称,与输入一致
 chapter_no:当前一级章节编号,与输入一致
 chapter_no:当前一级章节编号,与输入一致
-chapter_structure:当前一级章节下最终纳入的章节结构列表
-node_id:章节节点唯一标识
-node_name:章节名称
-node_no:章节编号
-node_level:章节层级,按数字表示
-parent_node_id:父节点ID,一级下直接子节点可为 null 或一级节点ID
-source_type:来源类型,single=单元呈现型,batch=批次处理型
-source_candidate_name:该节点对应的原始候选章节名称
+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
 is_selected:是否纳入,固定为 true
-structure_logic:当前一级章节内部结构构建逻辑说明
-###
+structure_logic:一段连续自然语言,说明本一级章节内部顺序、承接关系以及如何服务融资论证目标;不得分点枚举数量,不得新增结构概念
+######
 【输出约束】
 【输出约束】
-你必须严格遵守以下输出约束:
-只输出最终纳入的章节节点,不输出未纳入节点
 chapter_name 和 chapter_no 必须与输入完全一致
 chapter_name 和 chapter_no 必须与输入完全一致
-chapter_structure 必须按最终报告顺序输出
-node_name 必须来源于输入候选章节名称或批次展开对象名称,不得随意改写
-source_candidate_name 必须与输入候选章节名称完全一致
-source_type 只能取 single 或 batch
-node_level 必须与实际编号层级一致
-structure_logic 只能写一段自然语言,不得分点,不得写数量,不得新增结构概念
+chapter_structure 按最终阅读顺序输出
+不得输出未纳入的候选节点
 输出必须是可被程序直接解析的合法 JSON
 输出必须是可被程序直接解析的合法 JSON
-相关页面示例

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

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

BIN
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"]

BIN
algo/src/finrep_algo_agent/rag/ingestion/__pycache__/__init__.cpython-312.pyc


BIN
algo/src/finrep_algo_agent/rag/ingestion/__pycache__/chunking.cpython-312.pyc


BIN
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"]

BIN
algo/src/finrep_algo_agent/rag/retrieval/__pycache__/__init__.cpython-312.pyc


BIN
algo/src/finrep_algo_agent/rag/retrieval/__pycache__/formatting.cpython-312.pyc


BIN
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"]

BIN
algo/src/finrep_algo_agent/rag/vectorstore/__pycache__/__init__.cpython-312.pyc


BIN
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, []))

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

@@ -7,6 +7,17 @@ from finrep_algo_agent.schemas.outline import (
     OutlineL2Request,
     OutlineL2Request,
     OutlineL2Response,
     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
 from finrep_algo_agent.schemas.section import SectionRequest, SectionResponse, TokenUsage
 
 
 __all__ = [
 __all__ = [
@@ -17,6 +28,15 @@ __all__ = [
     "OutlineL1Response",
     "OutlineL1Response",
     "OutlineL2Request",
     "OutlineL2Request",
     "OutlineL2Response",
     "OutlineL2Response",
+    "RagDeleteResponse",
+    "RagDocumentIn",
+    "RagFileProcessResult",
+    "RagHit",
+    "RagIngestFilesResponse",
+    "RagIngestRequest",
+    "RagIngestResponse",
+    "RagRetrieveRequest",
+    "RagRetrieveResponse",
     "SectionRequest",
     "SectionRequest",
     "SectionResponse",
     "SectionResponse",
     "TokenUsage",
     "TokenUsage",

BIN
algo/src/finrep_algo_agent/schemas/__pycache__/__init__.cpython-312.pyc


BIN
algo/src/finrep_algo_agent/schemas/__pycache__/outline.cpython-312.pyc


BIN
algo/src/finrep_algo_agent/schemas/__pycache__/rag.cpython-312.pyc


BIN
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 消费合并后的结果。
+"""

+ 55 - 8
algo/src/finrep_algo_agent/schemas/outline.py

@@ -3,11 +3,17 @@ from __future__ import annotations
 from decimal import Decimal
 from decimal import Decimal
 from typing import Any
 from typing import Any
 
 
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
 
 
 
 
 class ChapterCandidate(BaseModel):
 class ChapterCandidate(BaseModel):
-    """一级章节候选;额外键(重要性、适用条件等)原样透传大模型。"""
+    """一级章节候选(预置知识体系 level=1);扩展字段随 JSON 原样写入提示词清单。
+
+    建议在 `chapter_candidates` 中与需求文档一致的扩展键包括(键名由 Java/预置库约定,示例如下):
+    - 重要性(有独立报告) / 重要性(无独立报告)
+    - 适用条件、备注、知识边界说明
+    - 末级段落统计上限(或同级语义字段)
+    """
 
 
     model_config = ConfigDict(extra="allow")
     model_config = ConfigDict(extra="allow")
 
 
@@ -30,15 +36,45 @@ class OutlineL1Request(BaseModel):
     candidate_financing_tools: list[str] = Field(default_factory=list)
     candidate_financing_tools: list[str] = Field(default_factory=list)
     recommended_financing_tools: list[str] = Field(default_factory=list)
     recommended_financing_tools: list[str] = Field(default_factory=list)
     other_requirements: str | None = None
     other_requirements: str | None = None
-    chapter_candidates: list[ChapterCandidate] = Field(default_factory=list)
+    chapter_candidates: list[ChapterCandidate] = Field(
+        default_factory=list,
+        description="可选:覆盖提示词模板内嵌的一级候选;为空则模板按 report_type 展示内置 Demo 清单",
+    )
 
 
 
 
 class ChapterL1Result(BaseModel):
 class ChapterL1Result(BaseModel):
     chapter_id: str
     chapter_id: str
     chapter_name: str
     chapter_name: str
+    presentation_enum: str = Field(
+        description="一级章节呈现方式:S1 独立一级章节呈现,S2 不呈现(需求文档 6.3.1.1)",
+    )
     paragraph_count_enum: str = Field(description="P0~P4")
     paragraph_count_enum: str = Field(description="P0~P4")
     reason: str = ""
     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):
 class OutlineL1Response(BaseModel):
     chapter_results: list[ChapterL1Result]
     chapter_results: list[ChapterL1Result]
@@ -50,14 +86,25 @@ class OutlineL2Request(BaseModel):
     tenant_id: str | None = None
     tenant_id: str | None = None
     chapter_name: str
     chapter_name: str
     chapter_no: 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_paragraph_count_enum: str | None = None
-    """一级大纲中该章的 reason,对应需求文档「判断说明」。"""
+    chapter_presentation_enum: str | None = Field(
+        default=None,
+        description="L1 该章 presentation_enum(S1/S2),对应需求文档 6.3.2「呈现方式」",
+    )
     chapter_reason: str = ""
     chapter_reason: str = ""
-    """一级大纲 overall_logic,对应「整体写作逻辑说明」。"""
     overall_logic: str = ""
     overall_logic: str = ""
-    leaf_chapter_candidates: list[dict[str, Any]] = Field(default_factory=list)
-    """可选:整段 L1 任务快照;若为空则用本对象下列字段拼报告背景。"""
-    l1_task_snapshot: OutlineL1Request | None = None
+    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
     report_type: str | None = None
     agreement_amount: Decimal | None = None
     agreement_amount: Decimal | None = None
     enterprise_type: str | None = None
     enterprise_type: str | None = None

+ 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)

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

@@ -19,6 +19,16 @@ class SectionRequest(BaseModel):
     data_package: dict[str, Any] = Field(default_factory=dict)
     data_package: dict[str, Any] = Field(default_factory=dict)
     example: str = ""
     example: str = ""
     notes: 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):
 class TokenUsage(BaseModel):

+ 2 - 1
algo/src/finrep_algo_agent/skills/__init__.py

@@ -1,5 +1,6 @@
 from finrep_algo_agent.skills.outline_l1 import run_outline_l1
 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.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
 from finrep_algo_agent.skills.section_gen import run_section
 
 
-__all__ = ["run_outline_l1", "run_outline_l2", "run_section"]
+__all__ = ["run_outline_l1", "run_outline_l2", "run_section", "RagService"]

BIN
algo/src/finrep_algo_agent/skills/__pycache__/__init__.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"]

BIN
algo/src/finrep_algo_agent/skills/outline_l1/__pycache__/__init__.cpython-312.pyc


BIN
algo/src/finrep_algo_agent/skills/outline_l1/__pycache__/outline_l1.cpython-312.pyc


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

@@ -27,6 +27,7 @@ def _stub_l1(req: OutlineL1Request) -> OutlineL1Response:
             ChapterL1Result(
             ChapterL1Result(
                 chapter_id=str(cid),
                 chapter_id=str(cid),
                 chapter_name=str(cname),
                 chapter_name=str(cname),
+                presentation_enum="S1",
                 paragraph_count_enum="P2",
                 paragraph_count_enum="P2",
                 reason="stub:占位理由",
                 reason="stub:占位理由",
             )
             )
@@ -39,6 +40,8 @@ def _stub_l1(req: OutlineL1Request) -> OutlineL1Response:
 
 
 def _validate_l1_against_request(req: OutlineL1Request, resp: OutlineL1Response) -> None:
 def _validate_l1_against_request(req: OutlineL1Request, resp: OutlineL1Response) -> None:
     cands = req.chapter_candidates or []
     cands = req.chapter_candidates or []
+    if not cands:
+        return
     if len(resp.chapter_results) != len(cands):
     if len(resp.chapter_results) != len(cands):
         raise ValueError(
         raise ValueError(
             f"chapter_results 数量({len(resp.chapter_results)})必须与候选章节数量({len(cands)})一致"
             f"chapter_results 数量({len(resp.chapter_results)})必须与候选章节数量({len(cands)})一致"

+ 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"]

BIN
algo/src/finrep_algo_agent/skills/outline_l2/__pycache__/__init__.cpython-312.pyc


BIN
algo/src/finrep_algo_agent/skills/outline_l2/__pycache__/outline_l2.cpython-312.pyc


+ 13 - 1
algo/src/finrep_algo_agent/skills/outline_l2.py → algo/src/finrep_algo_agent/skills/outline_l2/outline_l2.py

@@ -43,7 +43,16 @@ async def run_outline_l2(
     llm: LlmClient,
     llm: LlmClient,
 ) -> OutlineL2Response:
 ) -> OutlineL2Response:
     if settings.stub_skills:
     if settings.stub_skills:
-        return _stub_l2(req)
+        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)
     user_content = build_outline_l2_user_prompt(req)
     raw = await llm.chat_completion(
     raw = await llm.chat_completion(
@@ -59,6 +68,9 @@ async def run_outline_l2(
                 f"期望 {req.chapter_name}/{req.chapter_no},"
                 f"期望 {req.chapter_name}/{req.chapter_no},"
                 f"实际 {parsed.chapter_name}/{parsed.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
         return parsed
     except Exception as e:
     except Exception as e:
         logger.exception("L2 JSON parse/validate failed")
         logger.exception("L2 JSON parse/validate failed")

+ 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"]

BIN
algo/src/finrep_algo_agent/skills/rag_retrieve/__pycache__/__init__.cpython-312.pyc


BIN
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 - 39
algo/src/finrep_algo_agent/skills/section_gen.py

@@ -1,39 +0,0 @@
-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_section_user_prompt
-from finrep_algo_agent.schemas.section import SectionRequest, SectionResponse, TokenUsage
-
-logger = logging.getLogger(__name__)
-
-
-def _stub_section(req: SectionRequest) -> SectionResponse:
-    text = (
-        f"【占位正文】knowledge_unit_id={req.knowledge_unit_id} "
-        f"template_type={req.template_type}\n"
-        "(FINREP_STUB_SKILLS=true 时不调用模型。)"
-    )
-    return SectionResponse(generated_text=text, usage=TokenUsage())
-
-
-async def run_section(
-    req: SectionRequest,
-    *,
-    settings: Settings,
-    llm: LlmClient,
-) -> SectionResponse:
-    if settings.stub_skills:
-        return _stub_section(req)
-
-    user = build_section_user_prompt(req)
-    text = await llm.chat_completion(
-        [{"role": "user", "content": user}],
-        temperature=0.4,
-    )
-    return SectionResponse(
-        generated_text=text.strip(),
-        usage=TokenUsage(),
-    )

+ 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"]

BIN
algo/src/finrep_algo_agent/skills/section_gen/__pycache__/__init__.cpython-312.pyc


BIN
algo/src/finrep_algo_agent/skills/section_gen/__pycache__/section_gen.cpython-312.pyc


+ 82 - 0
algo/src/finrep_algo_agent/skills/section_gen/section_gen.py

@@ -0,0 +1,82 @@
+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_section_user_prompt
+from finrep_algo_agent.skills.rag_retrieve import RagService
+from finrep_algo_agent.schemas.section import SectionRequest, SectionResponse, TokenUsage
+
+logger = logging.getLogger(__name__)
+
+_ALLOWED_TEMPLATE_TYPES = frozenset({"info", "analysis", "metric", "judgment"})
+
+
+def _stub_section(req: SectionRequest) -> SectionResponse:
+    text = (
+        f"【占位正文】knowledge_unit_id={req.knowledge_unit_id} "
+        f"template_type={req.template_type}\n"
+        "(FINREP_STUB_SKILLS=true 时不调用模型。)"
+    )
+    return SectionResponse(generated_text=text, usage=TokenUsage())
+
+
+async def run_section(
+    req: SectionRequest,
+    *,
+    settings: Settings,
+    llm: LlmClient,
+    rag: RagService | None = None,
+) -> SectionResponse:
+    if settings.stub_skills:
+        return _stub_section(req)
+
+    effective = req
+    if req.rag_recall:
+        if not req.task_id:
+            raise ValueError("rag_recall=true 时必须提供 task_id")
+        if rag is None:
+            raise ValueError("RagService 未注入,无法执行召回")
+        if not (settings.embedding_api_key or settings.llm_api_key):
+            raise ValueError("RAG 召回需配置 FINREP_EMBEDDING_API_KEY 或 FINREP_LLM_API_KEY")
+        q = (req.rag_query or "").strip()
+        if not q:
+            q = "\n".join(
+                s
+                for s in (
+                    (req.paragraph_position or "").strip(),
+                    (req.paragraph_logic or "").strip(),
+                    f"knowledge_unit_id:{req.knowledge_unit_id}",
+                )
+                if s
+            )
+        recall = await rag.retrieve(
+            req.task_id,
+            q,
+            top_k=req.rag_top_k,
+            min_score=req.rag_min_score,
+        )
+        dp = dict(req.data_package)
+        dp["rag_recall"] = {
+            "query": q,
+            "hits": [h.model_dump() for h in recall.hits],
+            "formatted_context": recall.formatted_context,
+        }
+        effective = req.model_copy(update={"data_package": dp})
+
+    tt = (effective.template_type or "info").lower()
+    if tt not in _ALLOWED_TEMPLATE_TYPES:
+        raise ValueError(
+            f"template_type 必须是 info|analysis|metric|judgment 之一,当前为 {effective.template_type!r}"
+        )
+
+    user = build_section_user_prompt(effective)
+    text = await llm.chat_completion(
+        [{"role": "user", "content": user}],
+        temperature=0.4,
+    )
+    return SectionResponse(
+        generated_text=text.strip(),
+        usage=TokenUsage(),
+    )

BIN
algo/tests/__pycache__/test_health.cpython-312-pytest-9.0.2.pyc


BIN
algo/tests/__pycache__/test_prompts.cpython-312-pytest-9.0.2.pyc


BIN
algo/tests/__pycache__/test_rag.cpython-312-pytest-9.0.2.pyc


BIN
algo/tests/__pycache__/test_section_rag.cpython-312-pytest-9.0.2.pyc


+ 22 - 0
algo/tests/test_health.py

@@ -12,6 +12,14 @@ def test_health() -> None:
     assert data["status"] == "ok"
     assert data["status"] == "ok"
 
 
 
 
+def test_debug_runtime() -> None:
+    r = client.get("/debug/runtime")
+    assert r.status_code == 200
+    body = r.json()
+    assert "stub_skills" in body
+    assert "rag_defaults" in body
+
+
 def test_outline_l1_stub() -> None:
 def test_outline_l1_stub() -> None:
     r = client.post(
     r = client.post(
         "/v1/outline/l1",
         "/v1/outline/l1",
@@ -30,3 +38,17 @@ def test_section_stub() -> None:
     )
     )
     assert r.status_code == 200
     assert r.status_code == 200
     assert "generated_text" in r.json()
     assert "generated_text" in r.json()
+
+
+def test_outline_l2_s2_returns_empty_structure_in_stub() -> None:
+    r = client.post(
+        "/v1/outline/l2",
+        json={
+            "chapter_name": "企业基本情况分析",
+            "chapter_no": "1",
+            "chapter_presentation_enum": "S2",
+        },
+    )
+    assert r.status_code == 200
+    body = r.json()
+    assert body["chapter_structure"] == []

+ 54 - 0
algo/tests/test_prompts.py

@@ -19,6 +19,7 @@ def test_outline_l1_template_renders() -> None:
     assert "项目融资" in text
     assert "项目融资" in text
     assert "c1" in text
     assert "c1" in text
     assert "一级章节候选清单" in text
     assert "一级章节候选清单" in text
+    assert "presentation_enum" in text
 
 
 
 
 def test_outline_l2_template_renders() -> None:
 def test_outline_l2_template_renders() -> None:
@@ -30,6 +31,7 @@ def test_outline_l2_template_renders() -> None:
     req = OutlineL2Request(
     req = OutlineL2Request(
         chapter_name="融资方案",
         chapter_name="融资方案",
         chapter_no="3",
         chapter_no="3",
+        chapter_presentation_enum="S1",
         chapter_paragraph_count_enum="P2",
         chapter_paragraph_count_enum="P2",
         chapter_reason="保留",
         chapter_reason="保留",
         overall_logic="总逻辑",
         overall_logic="总逻辑",
@@ -40,6 +42,8 @@ def test_outline_l2_template_renders() -> None:
     assert "融资方案" in text
     assert "融资方案" in text
     assert "末级1" in text
     assert "末级1" in text
     assert "判断说明" in text
     assert "判断说明" in text
+    assert "呈现方式" in text
+    assert "S1" in text
 
 
 
 
 def test_section_template_renders() -> None:
 def test_section_template_renders() -> None:
@@ -57,3 +61,53 @@ def test_section_template_renders() -> None:
     text = build_section_user_prompt(req)
     text = build_section_user_prompt(req)
     assert "财务顾问" in text
     assert "财务顾问" in text
     assert "撰写" in text
     assert "撰写" in text
+
+
+def test_builtin_l1_embedded_in_template() -> None:
+    req = OutlineL1Request(report_type="项目融资", chapter_candidates=[])
+    text = build_outline_l1_user_prompt(req)
+    assert "pf-l1-01" in text
+    assert "企业基本情况分析" in text
+    assert "融资方案设计" in text
+
+
+def test_builtin_l2_embedded_branch() -> None:
+    l1 = OutlineL1Request(report_type="项目融资", agreement_amount=Decimal("1"))
+    req = OutlineL2Request(
+        chapter_name="企业财务情况分析",
+        chapter_no="2",
+        l1_chapter_id="pf-l1-02",
+        chapter_presentation_enum="S1",
+        chapter_paragraph_count_enum="P2",
+        chapter_reason="r",
+        overall_logic="g",
+        leaf_chapter_candidates=[],
+        l1_task_snapshot=l1,
+    )
+    text = build_outline_l2_user_prompt(req)
+    assert "pf-02-ku-1" in text or "财务概况" in text
+
+
+def test_builtin_l2_pf_l1_01_matches_requirement_doc_list() -> None:
+    l1 = OutlineL1Request(
+        report_type="项目融资",
+        agreement_amount=Decimal("40"),
+        enterprise_type="集团企业",
+        group_business_segments=["系统建设", "AI产品", "硬件设施"],
+    )
+    req = OutlineL2Request(
+        chapter_name="企业基本情况分析",
+        chapter_no="1",
+        l1_chapter_id="pf-l1-01",
+        chapter_presentation_enum="S1",
+        chapter_paragraph_count_enum="P2",
+        chapter_reason="无独立报告且为集团企业",
+        overall_logic="写作蓝图示例",
+        leaf_chapter_candidates=[],
+        l1_task_snapshot=l1,
+    )
+    text = build_outline_l2_user_prompt(req)
+    assert "企业基本情况概述" in text
+    assert "批次处理类" in text
+    assert "pf-01-ku-03" in text
+    assert "核心决策规则" in text

+ 76 - 0
algo/tests/test_rag.py

@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+from unittest.mock import AsyncMock
+
+import pytest
+
+from finrep_algo_agent.config import Settings
+from finrep_algo_agent.rag.ingestion import chunk_text, extract_text_from_upload
+from finrep_algo_agent.rag.vectorstore import InMemoryRagStore
+from finrep_algo_agent.schemas.rag import RagDocumentIn
+from finrep_algo_agent.skills.rag_retrieve import RagService
+
+
+def test_extract_plain_text_utf8() -> None:
+    ex = extract_text_from_upload(filename="a.txt", data="融资说明\n第二行".encode())
+    assert "融资说明" in ex.text
+    assert not ex.warning
+
+
+def test_chunk_text_splits_and_non_empty() -> None:
+    long = "第一段。\n\n" + "字" * 500 + "\n\n尾段"
+    chunks = chunk_text(long, chunk_size=120, overlap=20)
+    assert len(chunks) >= 2
+    assert all(c for c in chunks)
+
+
+@pytest.mark.asyncio
+async def test_rag_service_ingest_retrieve() -> None:
+    settings = Settings(
+        rag_chunk_size=200,
+        rag_chunk_overlap=40,
+        rag_default_top_k=3,
+        rag_embedding_batch_size=8,
+    )
+    store = InMemoryRagStore()
+
+    async def fake_embeddings(texts: list[str]) -> list[list[float]]:
+        return [[float(i % 5), float(len(t) % 3), 0.0, 1.0] for i, t in enumerate(texts)]
+
+    async def fake_embedding(q: str) -> list[float]:
+        return [1.0, 0.0, 0.0, 0.0]
+
+    mock_llm = AsyncMock()
+    mock_llm.embeddings = AsyncMock(side_effect=fake_embeddings)
+    mock_llm.embedding = AsyncMock(side_effect=fake_embedding)
+
+    svc = RagService(settings=settings, llm=mock_llm, store=store)
+    await svc.ingest(
+        "t1",
+        [
+            RagDocumentIn(
+                doc_id="d1",
+                title="测试",
+                text="融资主体基本情况说明。" * 30,
+                source_label="上传材料.pdf",
+            )
+        ],
+        replace=True,
+    )
+    out = await svc.retrieve("t1", "融资 主体", top_k=2, min_score=None)
+    assert out.hits
+    assert "RAG片段" in out.formatted_context or out.hits[0].text
+
+
+@pytest.mark.asyncio
+async def test_rag_delete_index() -> None:
+    settings = Settings(rag_chunk_size=500, rag_chunk_overlap=0)
+    store = InMemoryRagStore()
+    mock_llm = AsyncMock()
+    mock_llm.embeddings = AsyncMock(return_value=[[0.0, 1.0]])
+    mock_llm.embedding = AsyncMock(return_value=[0.0, 1.0])
+    svc = RagService(settings=settings, llm=mock_llm, store=store)
+    await svc.ingest("tx", [RagDocumentIn(doc_id="a", text="短文本")], replace=True)
+    assert store.list_task_chunks("tx")
+    assert svc.delete_index("tx")
+    assert not store.list_task_chunks("tx")

+ 49 - 0
algo/tests/test_section_rag.py

@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+from unittest.mock import AsyncMock
+
+import pytest
+
+from finrep_algo_agent.config import Settings
+from finrep_algo_agent.schemas.rag import RagHit, RagRetrieveResponse
+from finrep_algo_agent.schemas.section import SectionRequest
+from finrep_algo_agent.skills.section_gen.section_gen import run_section
+
+
+@pytest.mark.asyncio
+async def test_section_rag_recall_merges_into_data_package() -> None:
+    settings = Settings(stub_skills=False, llm_api_key="test-key")
+    mock_llm = AsyncMock()
+    mock_llm.chat_completion = AsyncMock(return_value=" 生成正文片段 ")
+
+    mock_rag = AsyncMock()
+    mock_rag.retrieve = AsyncMock(
+        return_value=RagRetrieveResponse(
+            hits=[
+                RagHit(
+                    chunk_id="c1",
+                    text="召回片段A",
+                    score=0.9,
+                    doc_id="d1",
+                )
+            ],
+            formatted_context="[RAG] 召回片段A",
+        )
+    )
+
+    req = SectionRequest(
+        knowledge_unit_id="ku-1",
+        template_type="info",
+        task_id="task-99",
+        rag_recall=True,
+        rag_query="融资主体",
+        paragraph_position="定位",
+        paragraph_logic="撰写逻辑",
+        data_package={"api": {"x": 1}},
+    )
+    resp = await run_section(req, settings=settings, llm=mock_llm, rag=mock_rag)
+    assert "生成正文片段" in resp.generated_text
+    mock_rag.retrieve.assert_awaited_once()
+    call_kw = mock_llm.chat_completion.await_args
+    prompt = call_kw[0][0][0]["content"]
+    assert "rag_recall" in prompt or "[RAG]" in prompt

BIN
docs/财顾报告智能化生成产品MVP版本需求文档.pdf