diff --git a/.gitignore b/.gitignore index a1c1b58..c805be8 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,8 @@ staticfiles/ .pytest_cache/ .coverage htmlcov/ +.tmp/ # OS .DS_Store Thumbs.db - diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..94ee56e --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,32 @@ +# Product + +## Register + +product + +## Users + +注册资料准备、法规审核和项目管理人员,在资料整理、法规核查、问题整改和申报文件填表过程中使用。 + +## Product Purpose + +DEMO-AGENT 是一个体外诊断试剂注册资料审核工作台。它把上传资料、文件汇总、法规规则核查、RAG 依据检索、风险预警、整改复核和申报表填充组织成可追溯的工作流。 + +## Brand Personality + +克制、可信、清晰。界面应服务审核任务,优先呈现状态、证据和下一步动作。 + +## Anti-references + +避免营销页式大标题、装饰性卡片堆叠、过度动画、过亮的渐变和不必要的视觉噪声。 + +## Design Principles + +- 证据优先:每个结论都应能回到来源文件、规则或检索片段。 +- 状态清楚:批次、节点、风险、异常和导出结果要一眼可辨。 +- 操作克制:页面提供必要动作,不把审核工作做成复杂后台。 +- 复用现有模式:新增页面沿用当前工作台导航、面板、表格和按钮体系。 + +## Accessibility & Inclusion + +默认按 WCAG AA 方向处理对比度、键盘可访问和清晰标签。动效仅用于状态反馈,并尊重减少动态效果需求。 diff --git a/README.md b/README.md index eb61ce8..021001d 100644 --- a/README.md +++ b/README.md @@ -1,248 +1,77 @@ -# Universal Agent Demo Framework +# DEMO-AGENT V2 -用于复试展示的通用 AI Agent Demo 框架。 +V2 是一个重置后的最小 Django 项目,仅保留基础配置和登录页面。 -项目目标不是提前猜中某一个具体业务题,而是先准备一个可快速改题的基础平台。拿到复试题目后,可以通过修改场景配置、上传知识库、补充少量工具函数,快速完成一个可演示的企业业务 Agent。 - -## 核心理念 - -```text -业务 Agent = 场景配置 + 知识库 + 工具集 + 输出模板 + 审计日志 + 模型适配器 -``` - -## 技术路线 - -V1 采用: - -- Django 单体应用 -- 独立 Agent Core 模块 -- SQLite -- Chroma -- Django Templates -- Docker Compose -- OpenAI API 兼容的 LLM 与 Embedding 接口 - -默认不强依赖 Dify。系统预留 Adapter 设计,后续可以接入 Dify、OpenAI Agents SDK 或其他 Agent 编排平台。 - -## 适用复试题型 - -| 题型 | 推荐场景模板 | -|---|---| -| SOP 问答 | `knowledge_qa` | -| 制度问答 | `knowledge_qa` | -| 文档审核 | `document_review` | -| 客服工单 | `ticket_assistant` | -| 质量异常分析 | `quality_analysis` | -| 财务审核 | `risk_audit` | -| 采购审核 | `risk_audit` | -| 合同风险分析 | `document_review` 或 `risk_audit` | - -## 模块划分 - -```text -config -apps.scenarios -apps.documents -apps.chat -apps.audit -agent_core -``` - -职责边界: - -- Django Apps 负责页面、数据、文件、日志等企业应用外壳。 -- Agent Core 负责 RAG、工具调用、模型适配、结构化输出和 Agent 编排。 -- RAG、工具调用和模型调用不直接写进 Django View。 - -## 推荐项目结构 - -```text -universal-agent-demo/ - manage.py - requirements.txt - Dockerfile - docker-compose.yml - .env.example - README.md - AGENTS.md - - config/ - apps/ - scenarios/ - documents/ - chat/ - audit/ - - agent_core/ - rag/ - tools/ - schemas/ - - configs/ - knowledge_qa.yaml - document_review.yaml - ticket_assistant.yaml - quality_analysis.yaml - risk_audit.yaml - - data/ - uploads/ - chroma/ - - docs/ -``` - -## V1 功能范围 - -V1 需要完成: - -- 场景列表。 -- Agent 对话页。 -- 文件上传。 -- 文档入库。 -- RAG 检索。 -- 内置工具调用。 -- 结构化输出展示。 -- 审计日志。 -- 模型 API 可配置。 -- Docker 一键启动。 - -当前代码基线已经落地的能力: - -- 首页支持展示场景摘要、适用题型、RAG 状态、工具数量。 -- 非法 YAML 场景配置会被自动跳过,并在首页展示错误摘要。 -- 对话页支持问题输入、文档范围选择、结构化结果、引用片段、工具调用和审计入口展示。 -- 文档页支持上传、列表查看、手动入库、失败原因提示和重试。 -- 审计页支持列表摘要、按场景筛选、详情查看、原始输出展示和敏感信息脱敏。 -- Agent Core 已具备 Prompt 编排、OpenAI 兼容 Provider、结构化输出解析、RAG 检索和工具注册机制。 -- 测试环境默认固定使用 Mock Provider,避免误调用本地真实模型配置。 - -V1 暂不重点做: - -- 多租户。 -- 复杂权限。 -- 完整工作流引擎。 -- 前后端分离。 -- 深度 Dify 集成。 -- 生产级高并发优化。 - -## 复试改题流程 - -拿到题目后: - -1. 判断题目属于哪类模板。 -2. 复制最接近的 YAML 场景配置。 -3. 修改 Agent 角色、目标、指令和输出模板。 -4. 上传题目材料。 -5. 如需业务计算,新增一个工具函数。 -6. 用 2 到 3 个问题测试效果。 -7. 演示场景配置、知识库引用、工具调用、结构化输出和审计日志。 - -## 当前页面概览 - -当前项目包含以下主要页面: - -| 页面 | 路径 | 当前能力 | -|---|---|---| -| 场景首页 | `/` | 展示场景名称、描述、适用题型、RAG 状态、工具数和配置异常摘要 | -| 对话页 | `/chat//` | 输入问题、勾选已入库文档、查看结构化结果、引用片段、工具调用和审计入口 | -| 文档列表页 | `/documents/` | 查看文档状态、错误信息、上传时间并手动触发入库 | -| 文档上传页 | `/documents/upload/` | 选择场景并上传 `.txt`、`.md`、`.pdf`、`.docx` 文件 | -| 审计列表页 | `/audit/` | 查看执行摘要并按场景筛选 | -| 审计详情页 | `/audit//` | 查看输入、最终回答、结构化输出、引用、工具调用、原始输出和错误信息 | - -## 计划启动方式 - -本地启动: - -```bash -pip install -r requirements.txt -python manage.py migrate -python manage.py runserver -``` - -Docker 启动: - -```bash -docker compose up --build -``` - -当前文档目标已统一为完整 V1 闭环:真实 Chroma RAG、OpenAI 兼容 LLM、OpenAI 兼容 Embedding、工具注册和审计日志。开发阶段可以用测试桩验证页面和边界,但不作为 V1 验收结果。 - -推荐首次启动步骤: +## 本地运行 ```bash python -m venv .venv .venv\Scripts\activate pip install -r requirements.txt python manage.py migrate +python manage.py createsuperuser python manage.py runserver ``` -## 环境变量 +访问: -项目当前通过 `os.environ` 读取配置,核心变量如下: +- 登录页:http://127.0.0.1:8000/login/ +- 首页:http://127.0.0.1:8000/ +- 管理后台:http://127.0.0.1:8000/admin/ -```env -DJANGO_SECRET_KEY=replace-with-a-local-secret-key -DJANGO_DEBUG=true -DJANGO_ALLOWED_HOSTS=* +## 文件汇总依赖 -LLM_API_KEY=your_llm_api_key -LLM_BASE_URL=https://api.openai.com/v1 -LLM_MODEL=gpt-4.1-mini - -EMBEDDING_API_KEY= -EMBEDDING_BASE_URL= -EMBEDDING_MODEL=text-embedding-3-small - -SCENARIO_CONFIG_DIR=configs -UPLOAD_ROOT=data/uploads -CHROMA_PATH=data/chroma -``` - -说明: - -- `EMBEDDING_API_KEY` 为空时,代码会自动复用 `LLM_API_KEY`。 -- `EMBEDDING_BASE_URL` 为空时,代码会自动复用 `LLM_BASE_URL`。 -- `.env.example` 只作为模板,不应填写真实密钥并提交到仓库。 -- 当前代码会在 Django settings 初始化时自动加载根目录 `.env`,本地 `python manage.py runserver`、`pytest` 和 Docker Compose 可以复用同一套配置。 -- Docker Compose 当前在 `docker-compose.yml` 中通过 `env_file` 读取 `.env`。 - -常见做法: - -- 本地开发:复制 `.env.example` 为 `.env`,填入真实参数后运行。 -- Docker 演示:确认 `.env` 已配置后,再执行 `docker compose up --build`。 - -## 测试与验证 - -当前项目已经补有较完整的模块级测试,覆盖: - -- 场景配置读取、非法配置容错和首页展示。 -- 对话提交、文档范围传递、结构化结果展示。 -- 文档上传、文本抽取、入库成功与失败提示。 -- 审计日志落库、筛选、原始输出展示和 API Key 脱敏。 -- Agent Core 的 Prompt 编排、结构化解析、RAG fallback 检索。 -- Tool Registry 和内置工具行为。 -- LLM / Embedding Provider 的配置与请求构造。 - -常用验证命令: +自动汇总文件目录与页数功能使用轻量 Python 库读取 PDF、Word、Excel、PowerPoint 文件。 +Docker 或生产环境如需处理 `.7z` 与 `.rar` 压缩包,还需要安装系统 `7z`/`p7zip` +命令,并确认以下命令可用: ```bash -pytest -python manage.py check -docker compose config +7z +7z i ``` -说明: +LibreOffice 不是必需依赖,仅作为未来增强老格式文档解析的可选能力。 -- 测试环境默认通过 `tests/conftest.py` 固定 `LLM_PROVIDER=mock`,避免回归测试误走真实网络请求。 -- 当前本地 `.env` 可能包含真实模型配置,但不会影响自动化测试稳定性。 +上传原始文件、批次工作目录和导出文件默认存储在 Django `MEDIA_ROOT` 下的 +`file_summary/users///` 或批次 `work_dir` 目录中。生产环境 +需要把 `MEDIA_ROOT` 挂载到持久化卷,并纳入备份或归档策略。 -## 文档入口 +## 飞书通知与问答预留 -- [V1 总需求文档](docs/需求分析/1.V1总需求文档.md) -- [模块需求文档索引](docs/需求分析/2.模块需求索引.md) -- [智能体总体设计](docs/设计文档/1.智能体总体设计.md) -- [设计文档索引](docs/设计文档/0.设计文档索引.md) -- [协作与编码约定](AGENTS.md) +飞书接入使用企业自建应用/智能体的消息 API。敏感信息只允许写入本地 `.env` +或部署环境变量,不要提交真实 App Secret、tenant token、open_id 或 user_id。 + +常用环境变量: + +| 变量名 | 用途 | +| --- | --- | +| `FEISHU_NOTIFY_ENABLED` | 是否启用真实飞书通知,未启用时只写未启用记录 | +| `FEISHU_NOTIFY_CHANNEL` | 通知通道,首期使用 `feishu_api` | +| `FEISHU_APP_ID` | 飞书应用 App ID | +| `FEISHU_APP_SECRET` | 飞书应用 App Secret | +| `FEISHU_DEFAULT_USER_OPEN_ID` | 默认个人接收人的 open_id,优先使用 | +| `FEISHU_DEFAULT_USER_ID` | 默认个人接收人的 user_id,open_id 为空时使用 | +| `FEISHU_DEFAULT_TARGET_NAME` | 默认接收人展示名,用于记录和页面展示 | +| `FEISHU_TENANT_TOKEN_CACHE_SECONDS` | tenant_access_token 缓存秒数 | +| `PUBLIC_BASE_URL` | 飞书消息中的系统入口根地址,默认 `http://127.0.0.1:8000` | + +自动化测试会 mock 飞书 token API 和消息 API,不请求真实飞书接口。真实发送只通过 +本地手动命令验证: + +```bash +python manage.py send_test_feishu_notification --username owner +``` + +问答预留能力可用本地模拟命令验证: + +```bash +python manage.py feishu_question_simulate --username owner "查最新法规核查" +``` + +集中测试建议在补齐 `.env` 后执行: + +```bash +python manage.py check +pytest tests/test_feishu_*.py +pytest tests/test_file_summary_workflow.py tests/test_regulatory_notification.py tests/test_application_form_fill_notification.py +``` diff --git a/config/asgi.py b/config/asgi.py index a713a97..d124534 100644 --- a/config/asgi.py +++ b/config/asgi.py @@ -1,8 +1,8 @@ +"""ASGI config for the project.""" import os from django.core.asgi import get_asgi_application - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") application = get_asgi_application() diff --git a/config/settings.py b/config/settings.py index f0e95b8..e971017 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,46 +1,33 @@ import os from pathlib import Path - BASE_DIR = Path(__file__).resolve().parent.parent -def load_dotenv(dotenv_path: Path) -> None: - """ - 读取根目录 `.env` 并注入进程环境。 +def load_env_file(file_path: Path) -> None: + """Loads a simple KEY=VALUE .env file into process env without extra deps.""" - 这里使用极简解析逻辑,目的是减少额外依赖, - 同时让本地 `runserver`、`pytest` 与 Docker Compose 共用一套配置文件。 - """ - if not dotenv_path.exists(): + if not file_path.exists(): return - for raw_line in dotenv_path.read_text(encoding="utf-8").splitlines(): + + for raw_line in file_path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) - key = key.strip() - value = value.strip().strip('"').strip("'") - os.environ.setdefault(key, value) + os.environ.setdefault(key.strip(), value.strip()) -load_dotenv(BASE_DIR / ".env") +load_env_file(BASE_DIR / ".env") - -def env_bool(name: str, default: bool = False) -> bool: - """将常见的字符串布尔值转换为 Python bool。""" - value = os.environ.get(name) - if value is None: - return default - return value.lower() in {"1", "true", "yes", "on"} - - -# Django 核心运行参数。 -SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-secret-key") -DEBUG = env_bool("DJANGO_DEBUG", True) +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "django-insecure-v2-local-development-key") +DEBUG = os.environ.get("DJANGO_DEBUG", "true").lower() == "true" ALLOWED_HOSTS = [ host.strip() - for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") + for host in os.environ.get( + "DJANGO_ALLOWED_HOSTS", + "127.0.0.1,localhost,testserver", + ).split(",") if host.strip() ] @@ -51,10 +38,7 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "apps.scenarios", - "apps.documents", - "apps.chat", - "apps.audit", + "review_agent", ] MIDDLEWARE = [ @@ -76,6 +60,7 @@ TEMPLATES = [ "APP_DIRS": True, "OPTIONS": { "context_processors": [ + "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", @@ -86,14 +71,20 @@ TEMPLATES = [ WSGI_APPLICATION = "config.wsgi.application" -# V1 默认使用 SQLite,确保本地演示零外部依赖。 DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "data" / "db.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", } } +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + LANGUAGE_CODE = "zh-hans" TIME_ZONE = "Asia/Shanghai" USE_I18N = True @@ -101,21 +92,88 @@ USE_TZ = True STATIC_URL = "static/" STATICFILES_DIRS = [BASE_DIR / "static"] +MEDIA_ROOT = BASE_DIR / "media" MEDIA_URL = "media/" -# 上传根目录可通过环境变量覆盖,便于 Docker 挂载到持久化目录。 -MEDIA_ROOT = Path(os.environ.get("UPLOAD_ROOT", BASE_DIR / "data" / "uploads")) - -# 配置目录和 Chroma 数据目录都允许外部覆盖,方便复试现场快速切换。 -SCENARIO_CONFIG_DIR = Path(os.environ.get("SCENARIO_CONFIG_DIR", BASE_DIR / "configs")) -CHROMA_PATH = Path(os.environ.get("CHROMA_PATH", BASE_DIR / "data" / "chroma")) - -# LLM 与 Embedding 默认遵循“尽量少配置也能跑”的策略: -# Embedding 未单独配置时自动复用 LLM 的 Key 和 Base URL。 -LLM_API_KEY = os.environ.get("LLM_API_KEY", "") -LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1") -LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-4.1-mini") -EMBEDDING_API_KEY = os.environ.get("EMBEDDING_API_KEY", LLM_API_KEY) -EMBEDDING_BASE_URL = os.environ.get("EMBEDDING_BASE_URL", LLM_BASE_URL) -EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "text-embedding-3-small") DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +LOGIN_URL = "login" +LOGIN_REDIRECT_URL = "home" +LOGOUT_REDIRECT_URL = "login" + +LLM_API_KEY = os.environ.get("LLM_API_KEY", "") +LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.siliconflow.cn/v1") +LLM_MODEL = os.environ.get("LLM_MODEL", "") + +REGULATORY_RAG_PROVIDER = os.environ.get("REGULATORY_RAG_PROVIDER", "siliconflow") +REGULATORY_RAG_CHROMA_PATH = os.environ.get( + "REGULATORY_RAG_CHROMA_PATH", + str(MEDIA_ROOT / "regulatory_review" / "rag" / "chroma"), +) +REGULATORY_RAG_COLLECTION = os.environ.get( + "REGULATORY_RAG_COLLECTION", + "nmpa_ivd_registration_v1", +) +REGULATORY_REVIEW_ASYNC = os.environ.get("REGULATORY_REVIEW_ASYNC", "true").lower() == "true" +REGULATORY_LLM_REVIEW_MAX_ATTEMPTS = int(os.environ.get("REGULATORY_LLM_REVIEW_MAX_ATTEMPTS", "3")) +REGULATORY_LLM_REVIEW_RETRY_DELAY_SECONDS = float(os.environ.get("REGULATORY_LLM_REVIEW_RETRY_DELAY_SECONDS", "0.5")) +REGULATORY_LLM_REVIEW_TIMEOUT_SECONDS = float(os.environ.get("REGULATORY_LLM_REVIEW_TIMEOUT_SECONDS", "15")) +SILICONFLOW_BASE_URL = os.environ.get("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1") +SILICONFLOW_API_KEY = os.environ.get("SILICONFLOW_API_KEY", LLM_API_KEY) +SILICONFLOW_EMBEDDING_MODEL = os.environ.get( + "SILICONFLOW_EMBEDDING_MODEL", + "Qwen/Qwen3-Embedding-4B", +) +SILICONFLOW_EMBEDDING_DIMENSIONS = int(os.environ.get("SILICONFLOW_EMBEDDING_DIMENSIONS", "1024")) + +FEISHU_NOTIFY_ENABLED = os.environ.get("FEISHU_NOTIFY_ENABLED", "false").lower() == "true" +FEISHU_NOTIFY_CHANNEL = os.environ.get("FEISHU_NOTIFY_CHANNEL", "feishu_api") +FEISHU_APP_ID = os.environ.get("FEISHU_APP_ID", "") +FEISHU_APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "") +FEISHU_DEFAULT_USER_OPEN_ID = os.environ.get("FEISHU_DEFAULT_USER_OPEN_ID", "") +FEISHU_DEFAULT_USER_ID = os.environ.get("FEISHU_DEFAULT_USER_ID", "") +FEISHU_DEFAULT_TARGET_NAME = os.environ.get("FEISHU_DEFAULT_TARGET_NAME", "默认飞书接收人") +FEISHU_TENANT_TOKEN_CACHE_SECONDS = int(os.environ.get("FEISHU_TENANT_TOKEN_CACHE_SECONDS", "6600")) +FEISHU_TOKEN_API_URL = os.environ.get( + "FEISHU_TOKEN_API_URL", + "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", +) +FEISHU_MESSAGE_API_URL = os.environ.get( + "FEISHU_MESSAGE_API_URL", + "https://open.feishu.cn/open-apis/im/v1/messages", +) +PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "http://127.0.0.1:8000") + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "filters": { + "suppress_workflow_status_poll": { + "()": "review_agent.logging_filters.SuppressWorkflowStatusPollFilter", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + "filters": ["suppress_workflow_status_poll"], + }, + }, + "formatters": { + "verbose": { + "format": "%(asctime)s %(levelname)s %(name)s %(message)s", + }, + }, + "loggers": { + "review_agent": { + "handlers": ["console"], + "level": os.environ.get("REVIEW_AGENT_LOG_LEVEL", "INFO"), + "propagate": True, + }, + "django.server": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + }, +} diff --git a/config/urls.py b/config/urls.py index 1925b8e..3de58ba 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,18 +1,32 @@ -from django.conf import settings -from django.conf.urls.static import static from django.contrib import admin +from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView from django.urls import include, path +from review_agent.views import attachment_manager, home_dashboard, knowledge_base_manager, stream_chat, workspace -# 总路由只承担模块装配职责,不在这里写业务逻辑。 urlpatterns = [ + path("", home_dashboard, name="home"), + path("chat/", workspace, name="chat"), + path("knowledge-base/", knowledge_base_manager, name="knowledge_base_manager"), + path("attachments/", attachment_manager, name="attachment_manager"), + path("", include("review_agent.urls")), + path("chat/stream/", stream_chat, name="chat_stream"), + path( + "login/", + LoginView.as_view( + template_name="registration/login.html", + redirect_authenticated_user=True, + ), + name="login", + ), + path("logout/", LogoutView.as_view(), name="logout"), + path( + "password/change/", + PasswordChangeView.as_view( + template_name="registration/password_change.html", + success_url="/", + ), + name="password_change", + ), path("admin/", admin.site.urls), - path("", include("apps.scenarios.urls")), - path("chat/", include("apps.chat.urls")), - path("documents/", include("apps.documents.urls")), - path("audit/", include("apps.audit.urls")), ] - -if settings.DEBUG: - # 开发环境下直接通过 Django 提供上传文件访问能力,便于本地演示。 - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/config/wsgi.py b/config/wsgi.py index 3f41533..25bf4d0 100644 --- a/config/wsgi.py +++ b/config/wsgi.py @@ -1,8 +1,8 @@ +"""WSGI config for the project.""" import os from django.core.wsgi import get_wsgi_application - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") application = get_wsgi_application() diff --git a/docs/0.原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx b/docs/0.原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx new file mode 100644 index 0000000..6c81cb5 Binary files /dev/null and b/docs/0.原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx differ diff --git a/docs/0.原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent/【模拟题二】试剂盒临床注册文件准备与审核Agent.md b/docs/0.原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent/【模拟题二】试剂盒临床注册文件准备与审核Agent.md new file mode 100644 index 0000000..7ef3d5f --- /dev/null +++ b/docs/0.原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent/【模拟题二】试剂盒临床注册文件准备与审核Agent.md @@ -0,0 +1,62 @@ +**试剂盒临床注册文件准备与审核智能体搭建** + +**一、背景** + +卡尤迪生物研发团队在推进NMPA(国家药品监督管理局)注册申报时,需准备大量合规性文件,包括产品技术要求、说明书、检测报告、临床评估资料等。 + +公司计划组建AI Agent新团队,目标为"试剂盒NMPA注册文件准备与审核智能体",实现文件目录自动汇总、法规完整性检查、关键信息自动提取与填写、缺失文件预警、文档一致性核查,提升注册效率并降低合规风险。 + +**二、任务目标** + +请你作为 AI Agent 工程师候选人,设计并实现(或详细描述)一个智能体,能够: + +1. 自动汇总注册申报文件夹中的所有文件及页数 +2. 对照 NMPA 法规要求核查文件完整性并预警缺失 +3. 提取产品关键信息并自动填写至申报文件 +4. 核查文档结构与信息一致性 +5. 输出合规风险预警与处理建议 + +**三、具体要求如下** + +**1. 自动汇总文件夹文件目录与页数。** + +文件目录参考附件。 + +**2. 按照NMPA现行法规要求核查文件完整性。** + +- 对照NMPA法规检查所需文件是否齐全(如注册申报资料基本要求、产品技术要求、注册检验报告等) +- 自动识别缺失文件并通知责任人 +- 参考法规来源网站: + + + + + +**3. 从产品文件中提取关键信息并自动填写至目标文件。** + +- 自动提取:产品名称、检测靶标、适用范围、储存条件、性能指标等核心信息 +- 将提取信息自动填入注册申报表格或对照清单 + +**4. 核查文档结构、信息一致性与章节规范性。** + +- 检测章节是否完整(如分析灵敏度、特异性、重复性等必检项目) +- 不同文档间同一信息是否一致(如产品名称、规格型号等) +- 格式是否符合NMPA要求的规范章节结构 + +**5. 提供合规风险预警与处理建议。** + +例如:"文件X:缺少临床评估报告,请补充"或"产品Y:说明书与检测报告中的适用范围描述不一致,请核对" + +**附加要求【在复试时陈述,需结合 Demo 演示】** + +**1. 架构搭建思路(基于 Demo 版)** + +- 展示Demo运行结果(文件目录汇总表、法规完整性报告、信息提取对照表、异常预警列表) +- 结合你实现的Demo,说明智能体的整体工作流(如:文件扫描 → 目录汇总 → 法规匹配 → 信息提取 → 一致性核查 → 风险预警) +- 展示Demo中实际调用的关键工具/库(如 pdfplumber / PyMuPDF、正则表达式、规则引擎、向量检索等),并分析选用理由 +- 简述Demo中如何体现文件完整性检测、信息一致性核查、法规条款匹配等难点规则的处理 + +**2. 基于 Demo 版的迭代规划** + +- 说明当前Demo实现了哪些核心功能,哪些是模拟数据/简化逻辑 +- 下一版本最想增加的一个功能以及需要投入的技术资源(如 NMPA 官网 API 对接、文件版本管理、多语言支持等),并说明为什么优先做它 diff --git a/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/中华人民共和国医疗器械变更注册(备案)文件(体外诊断试剂)(格式).doc b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/中华人民共和国医疗器械变更注册(备案)文件(体外诊断试剂)(格式).doc new file mode 100644 index 0000000..af0ff63 Binary files /dev/null and b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/中华人民共和国医疗器械变更注册(备案)文件(体外诊断试剂)(格式).doc differ diff --git a/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx new file mode 100644 index 0000000..337c1f5 Binary files /dev/null and b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx differ diff --git a/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂变更备案-变更注册申报资料要求及说明.doc b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂变更备案-变更注册申报资料要求及说明.doc new file mode 100644 index 0000000..ad84e26 Binary files /dev/null and b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂变更备案-变更注册申报资料要求及说明.doc differ diff --git a/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂安全和性能基本原则清单.doc b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂安全和性能基本原则清单.doc new file mode 100644 index 0000000..8c50bb5 Binary files /dev/null and b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂安全和性能基本原则清单.doc differ diff --git a/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂延续注册申报资料要求及说明.doc b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂延续注册申报资料要求及说明.doc new file mode 100644 index 0000000..e5962b1 Binary files /dev/null and b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂延续注册申报资料要求及说明.doc differ diff --git a/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂注册申报资料要求及说明.doc b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂注册申报资料要求及说明.doc new file mode 100644 index 0000000..769a99a Binary files /dev/null and b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂注册申报资料要求及说明.doc differ diff --git a/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/医疗器械注册申报资料和批准证明文件格式要求(体外诊断试剂).doc b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/医疗器械注册申报资料和批准证明文件格式要求(体外诊断试剂).doc new file mode 100644 index 0000000..2fb6529 Binary files /dev/null and b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/医疗器械注册申报资料和批准证明文件格式要求(体外诊断试剂).doc differ diff --git a/docs/0.原始材料/目标产品说明书.docx b/docs/0.原始材料/目标产品说明书.docx new file mode 100644 index 0000000..56ab2b1 Binary files /dev/null and b/docs/0.原始材料/目标产品说明书.docx differ diff --git a/docs/0.原始材料/第1章 监管信息.rar b/docs/0.原始材料/第1章 监管信息.rar new file mode 100644 index 0000000..266bbd8 Binary files /dev/null and b/docs/0.原始材料/第1章 监管信息.rar differ diff --git a/docs/0.原始材料/第1章 监管信息/CH1.11.1 符合标准的清单.docx b/docs/0.原始材料/第1章 监管信息/CH1.11.1 符合标准的清单.docx new file mode 100644 index 0000000..a48c40b Binary files /dev/null and b/docs/0.原始材料/第1章 监管信息/CH1.11.1 符合标准的清单.docx differ diff --git a/docs/0.原始材料/第1章 监管信息/CH1.11.5 真实性声明.docx b/docs/0.原始材料/第1章 监管信息/CH1.11.5 真实性声明.docx new file mode 100644 index 0000000..8ff74b1 Binary files /dev/null and b/docs/0.原始材料/第1章 监管信息/CH1.11.5 真实性声明.docx differ diff --git a/docs/0.原始材料/第1章 监管信息/CH1.11.6 符合性声明.docx b/docs/0.原始材料/第1章 监管信息/CH1.11.6 符合性声明.docx new file mode 100644 index 0000000..a9d4fcd Binary files /dev/null and b/docs/0.原始材料/第1章 监管信息/CH1.11.6 符合性声明.docx differ diff --git a/docs/0.原始材料/第1章 监管信息/CH1.2 监管信息目录.docx b/docs/0.原始材料/第1章 监管信息/CH1.2 监管信息目录.docx new file mode 100644 index 0000000..d9ddb42 Binary files /dev/null and b/docs/0.原始材料/第1章 监管信息/CH1.2 监管信息目录.docx differ diff --git a/docs/0.原始材料/第1章 监管信息/CH1.4 申请表.docx b/docs/0.原始材料/第1章 监管信息/CH1.4 申请表.docx new file mode 100644 index 0000000..e871c80 Binary files /dev/null and b/docs/0.原始材料/第1章 监管信息/CH1.4 申请表.docx differ diff --git a/docs/0.原始材料/第1章 监管信息/CH1.5 产品列表.docx b/docs/0.原始材料/第1章 监管信息/CH1.5 产品列表.docx new file mode 100644 index 0000000..f8cb5b6 Binary files /dev/null and b/docs/0.原始材料/第1章 监管信息/CH1.5 产品列表.docx differ diff --git a/docs/0.原始材料/第1章 监管信息/CH1.9 产品申报前沟通的说明.doc b/docs/0.原始材料/第1章 监管信息/CH1.9 产品申报前沟通的说明.doc new file mode 100644 index 0000000..f3f2630 Binary files /dev/null and b/docs/0.原始材料/第1章 监管信息/CH1.9 产品申报前沟通的说明.doc differ diff --git a/docs/0.原始材料/附件 4 体外诊断试剂注册申报资料要求及说明.doc b/docs/0.原始材料/附件 4 体外诊断试剂注册申报资料要求及说明.doc new file mode 100644 index 0000000..769a99a Binary files /dev/null and b/docs/0.原始材料/附件 4 体外诊断试剂注册申报资料要求及说明.doc differ diff --git a/docs/1.需求分析/1.自动汇总.md b/docs/1.需求分析/1.自动汇总.md new file mode 100644 index 0000000..9726de5 --- /dev/null +++ b/docs/1.需求分析/1.自动汇总.md @@ -0,0 +1,328 @@ +# 自动汇总文件夹文件目录与页数流程需求分析 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 原始材料 | docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx | +| 功能主题 | 自动汇总注册申报资料文件目录与页数 | +| 分析日期 | 2026-06-05 | +| 分析版本 | V1.0 | + +--- + +## 一、需求背景 + +试剂盒 NMPA 注册申报过程中,研发或注册人员需要准备大量文件,包括产品技术要求、说明书、检测报告、临床评估资料等。原始材料中明确提出智能体需要具备“自动汇总注册申报文件夹中的所有文件及页数”的能力,作为后续法规完整性检查、缺失文件预警、信息提取和一致性核查的基础数据来源。 + +本功能目标是:用户在 AI 对话框上传注册申报资料压缩包、文件夹或多个散装文件后,系统自动扫描资料结构,统计各文件的目录层级、文件类型、页数、路径和处理状态,生成 Markdown 汇总报告与 Excel 文件,并在 AI 对话框中展示简表和下载链接,同时将汇总结果存入数据库。 + +--- + +## 二、需求范围 + +### 2.1 本期范围 + +| 序号 | 范围项 | 说明 | +| --- | --- | --- | +| 1 | 文件上传入口 | 用户通过现有 AI 对话框上传压缩包、文件夹或多个散装文件 | +| 2 | 文件解析 | 系统识别上传内容并展开为待扫描文件清单 | +| 3 | 目录汇总 | 保留原始目录层级,散装文件归入默认上传批次目录 | +| 4 | 页数统计 | 默认支持 pdf、doc、docx、xls、xlsx、ppt、pptx | +| 5 | 结果展示 | AI 对话框中展示 Markdown 简表 | +| 6 | 文件导出 | 生成 Markdown 报告与 Excel 汇总表,并在对话框提供下载链接 | +| 7 | 数据存档 | 上传批次、文件明细、页数、异常状态、导出文件信息写入数据库 | + +### 2.2 非本期范围 + +| 序号 | 范围项 | 说明 | +| --- | --- | --- | +| 1 | NMPA 法规完整性核查 | 依赖目录汇总结果,属于后续流程 | +| 2 | 产品关键信息抽取 | 依赖具体文档内容解析,属于后续流程 | +| 3 | 文档一致性核查 | 依赖信息抽取结果,属于后续流程 | +| 4 | 合规风险预警 | 本功能仅输出文件扫描异常,不输出法规合规风险 | + +--- + +## 三、用户角色与使用场景 + +| 角色 | 诉求 | 典型场景 | +| --- | --- | --- | +| 注册人员 | 快速了解申报资料是否完整、页数是否可统计 | 上传供应商或研发团队整理好的注册资料压缩包 | +| 审核人员 | 获得可复核的文件目录和页数清单 | 在审核前获取 Markdown 简表和 Excel 明细 | +| 系统管理员 | 追踪上传批次和处理异常 | 查看数据库存档,定位文件解析失败原因 | + +--- + +## 四、业务流程分析 + +### 4.1 主流程 + +```text +用户进入 AI 对话框 +-> 上传压缩包、文件夹或多个散装文件 +-> 用户发送“自动汇总文件目录与页数”类指令 +-> 系统创建上传批次 +-> 系统保存原始上传文件 +-> 系统识别上传类型 +-> 如为压缩包则解压,如为文件夹或散装文件则直接扫描 +-> 系统遍历文件并识别目录层级 +-> 系统过滤支持的文件类型 +-> 系统计数每个支持文件的页数 +-> 系统记录不支持或统计失败的文件异常 +-> 系统生成 Markdown 报告与 Excel 文件 +-> 系统在 AI 对话框展示 Markdown 简表和下载链接 +-> 系统将批次和明细数据写入数据库 +-> 结束 +``` + +### 4.2 分支流程 + +| 分支场景 | 流程说明 | +| --- | --- | +| 上传压缩包 | 系统校验压缩包格式,解压至临时工作目录,按解压后的目录结构统计 | +| 上传文件夹 | 系统保留文件夹层级,扫描所有子目录文件 | +| 上传多个散装文件 | 系统生成默认批次目录,将所有文件视为同一层级 | +| 存在不支持类型 | 文件进入明细表,页数为空,统计状态为“不支持”,异常说明记录文件类型 | +| 单个文件页数统计失败 | 不中断整个批次,统计状态为“失败”,异常说明记录失败原因 | +| 重名文件 | 以完整相对路径区分;散装文件重名时使用系统保存路径或自动重命名策略避免覆盖 | + +### 4.3 异常流程 + +| 异常场景 | 处理方式 | +| --- | --- | +| 上传文件为空 | 对话框提示“未检测到可处理文件”,不生成报告 | +| 压缩包损坏或无法解压 | 批次状态标记为失败,对话框展示错误原因 | +| 文件被加密或受保护 | 明细记录该文件,页数统计失败,异常说明标记“文件加密或无法读取” | +| 文件过大或处理超时 | 明细记录超时状态,批次可继续处理其他文件 | +| Excel/Markdown 导出失败 | 对话框提示导出失败,数据库保留扫描明细与失败原因 | + +--- + +## 五、功能模块梳理 + +### 5.1 核心功能 + +| 序号 | 功能名称 | 功能描述 | 优先级 | +| --- | --- | --- | --- | +| 1 | 上传资料接收 | 支持用户在 AI 对话框上传压缩包、文件夹或多个散装文件 | P0 | +| 2 | 上传批次管理 | 为每次汇总创建批次编号,记录用户、时间、来源会话和处理状态 | P0 | +| 3 | 压缩包解压 | 支持压缩包上传后的安全解压和目录结构保留 | P0 | +| 4 | 文件遍历扫描 | 递归扫描上传范围内所有文件,生成相对路径和目录层级 | P0 | +| 5 | 文件类型识别 | 根据扩展名和必要的文件头信息识别 pdf、doc、docx、xls、xlsx、ppt、pptx | P0 | +| 6 | 页数统计 | 对支持文件统计页数或页签/幻灯片数量 | P0 | +| 7 | Markdown 简表展示 | 在 AI 对话框展示可解析的 Markdown 表格摘要 | P0 | +| 8 | Markdown 报告导出 | 生成完整 Markdown 汇总报告 | P0 | +| 9 | Excel 明细导出 | 生成 Excel 文件,包含所有文件明细和统计状态 | P0 | +| 10 | 数据库存档 | 保存批次、文件明细、统计结果、异常说明和导出文件记录 | P0 | + +### 5.2 辅助功能 + +| 序号 | 功能名称 | 功能描述 | 优先级 | +| --- | --- | --- | --- | +| 1 | 下载链接生成 | 在 AI 回复中提供 Markdown 报告和 Excel 文件下载链接 | P0 | +| 2 | 处理进度提示 | 对较大批次展示“上传中、解析中、统计中、导出中、已完成”等状态 | P1 | +| 3 | 文件过滤规则 | 支持过滤系统隐藏文件、临时文件和空文件 | P1 | +| 4 | 统计摘要 | 输出文件总数、支持文件数、统计成功数、失败数、总页数 | P1 | +| 5 | 历史记录查询 | 可通过对话记录或批次记录回看历史汇总结果 | P1 | + +### 5.3 隐含功能 + +| 序号 | 功能名称 | 功能描述 | 来源说明 | +| --- | --- | --- | --- | +| 1 | AI 对话框附件上传能力 | 现有对话框需要支持上传附件并关联会话消息 | 用户要求“在 AI 对话框中提供下载连接” | +| 2 | Markdown 渲染能力 | 现有对话框需要能解析表格、链接等 Markdown 内容 | 用户要求“对话框能正常解析 md 格式” | +| 3 | 文件访问权限控制 | 下载链接只能允许当前用户或授权用户访问 | 涉及注册资料敏感文件 | +| 4 | 临时文件清理 | 解压目录和处理中间文件需要定期清理 | 压缩包和大文件处理会产生临时文件 | + +--- + +## 六、数据实体分析 + +### 6.1 实体列表 + +| 序号 | 实体名称 | 字段说明 | 关联实体 | +| --- | --- | --- | --- | +| 1 | 汇总批次 | 批次编号、用户、会话、上传类型、文件数量、总页数、状态、创建时间、完成时间 | 会话、文件明细、导出文件 | +| 2 | 文件明细 | 文件序号、目录层级、文件名、文件类型、页数、路径、统计状态、异常说明 | 汇总批次 | +| 3 | 导出文件 | 文件类型、文件名、保存路径、下载地址、生成状态、生成时间 | 汇总批次 | +| 4 | 上传原始文件 | 原始文件名、保存路径、大小、文件类型、上传时间 | 汇总批次 | + +### 6.2 文件明细字段 + +| 字段 | 类型建议 | 必填 | 说明 | +| --- | --- | --- | --- | +| file_index | Integer | 是 | 文件序号,按目录遍历顺序生成 | +| directory_level | String | 是 | 目录层级,如“1/2/3”或“注册资料/产品技术要求” | +| file_name | String | 是 | 文件名,不含目录 | +| file_type | String | 是 | 文件扩展名或识别后的类型 | +| page_count | Integer | 否 | 页数,统计失败或不支持时为空 | +| relative_path | String | 是 | 相对上传根目录的路径 | +| statistics_status | String | 是 | 成功、失败、不支持、跳过 | +| error_message | Text | 否 | 异常说明 | + +### 6.3 实体关系 + +```text +会话 1:N 汇总批次 +汇总批次 1:N 上传原始文件 +汇总批次 1:N 文件明细 +汇总批次 1:N 导出文件 +``` + +--- + +## 七、输出要求 + +### 7.1 AI 对话框简表 + +AI 回复内容必须使用前端可解析的 Markdown 格式,至少包含统计摘要、文件简表和下载链接。 + +示例: + +```markdown +已完成文件目录与页数汇总。 + +| 指标 | 数量 | +| --- | --- | +| 文件总数 | 24 | +| 统计成功 | 21 | +| 统计失败 | 2 | +| 不支持 | 1 | +| 总页数 | 386 | + +| 序号 | 目录层级 | 文件名 | 类型 | 页数 | 状态 | 异常说明 | +| --- | --- | --- | --- | --- | --- | --- | +| 1 | 注册资料/说明书 | 说明书.docx | docx | 12 | 成功 | | +| 2 | 注册资料/检测报告 | 检测报告.pdf | pdf | 38 | 成功 | | + +[下载 Markdown 报告](download-url) +[下载 Excel 明细](download-url) +``` + +### 7.2 Markdown 报告 + +Markdown 报告应包含: + +| 模块 | 内容 | +| --- | --- | +| 汇总信息 | 批次编号、上传用户、上传时间、处理完成时间、上传类型 | +| 统计摘要 | 文件总数、支持文件数、成功数、失败数、不支持数、总页数 | +| 文件明细 | 文件序号、目录层级、文件名、文件类型、页数、路径、统计状态、异常说明 | +| 异常清单 | 统计失败、不支持、解压失败、加密文件、超时文件 | +| 处理说明 | 支持范围、页数统计口径、待确认事项 | + +### 7.3 Excel 导出 + +Excel 至少包含两个工作表: + +| 工作表 | 内容 | +| --- | --- | +| 汇总信息 | 批次信息、统计摘要 | +| 文件明细 | 文件序号、目录层级、文件名、文件类型、页数、路径、统计状态、异常说明 | + +--- + +## 八、页数统计口径 + +| 文件类型 | 统计口径 | 备注 | +| --- | --- | --- | +| pdf | PDF 页面数量 | 可使用 pdfplumber 或 PyMuPDF | +| doc | Word 文档页数 | 可通过 LibreOffice 转 PDF 后统计,或读取文档属性;实现方案待技术验证 | +| docx | Word 文档页数 | 可通过 LibreOffice 转 PDF 后统计,或读取文档属性;实现方案待技术验证 | +| xls | Excel 工作表打印页数或页签数量 | 具体口径待确认 | +| xlsx | Excel 工作表打印页数或页签数量 | 具体口径待确认 | +| ppt | 幻灯片数量 | 可转换或读取演示文稿结构 | +| pptx | 幻灯片数量 | 可读取演示文稿结构 | + +> 待确认:Excel 的“页数”是否按打印分页统计,还是按工作表数量统计。建议 Demo 阶段先按工作表数量统计,并在报告中明确口径。 + +--- + +## 九、非功能性需求 + +### 9.1 性能要求 + +| 项目 | 要求 | +| --- | --- | +| 小批次文件 | 50 个文件以内,应在 30 秒内完成汇总 | +| 中等批次文件 | 200 个文件以内,应支持异步处理或进度提示 | +| 大文件处理 | 单文件超时不影响其他文件统计 | + +### 9.2 安全要求 + +| 项目 | 要求 | +| --- | --- | +| 上传安全 | 限制可处理类型,避免执行上传文件中的脚本或宏 | +| 解压安全 | 防止路径穿越,限制解压目录在系统工作目录内 | +| 下载权限 | 下载链接需要校验登录用户和会话权限 | +| 数据隔离 | 不同用户上传文件和统计结果不可互相访问 | +| 敏感资料保护 | 原始上传文件、报告和 Excel 文件应存储在受控目录 | + +### 9.3 可用性要求 + +| 项目 | 要求 | +| --- | --- | +| 对话展示 | Markdown 表格、链接在现有 AI 对话框中可正常渲染 | +| 失败可见 | 失败文件需要展示具体原因,便于用户补传或修复 | +| 可追溯 | 每份导出报告能关联到具体上传批次和会话 | + +--- + +## 十、疑问点与待确认事项 + +### 10.1 功能疑问 + +| 序号 | 疑问内容 | 建议 | 状态 | +| --- | --- | --- | --- | +| 1 | “文件目录参考附件”尚未上传,无法确认标准目录样例 | 附件上传后补充目录样例和字段映射 | 待确认 | +| 2 | 文件夹上传在浏览器端是否采用目录上传能力 | 前端可使用 webkitdirectory 或改为要求用户上传压缩包 | 待确认 | +| 3 | 散装文件是否需要用户手动指定归属目录 | Demo 阶段统一归入“散装上传”目录 | 待确认 | + +### 10.2 业务规则疑问 + +| 序号 | 疑问内容 | 建议 | 状态 | +| --- | --- | --- | --- | +| 1 | Excel 页数统计口径不明确 | Demo 阶段按工作表数量,正式版可按打印分页 | 待确认 | +| 2 | doc/docx 页数是否必须与 Word 打开后的实际页数完全一致 | 建议通过转换 PDF 后统计,提高一致性 | 待确认 | +| 3 | 是否需要按 NMPA 申报资料目录自动排序 | 本功能先按上传目录顺序,后续法规完整性检查再做标准目录匹配 | 待确认 | + +### 10.3 技术方案疑问 + +| 序号 | 疑问内容 | 可选方案 | 状态 | +| --- | --- | --- | --- | +| 1 | Office 文档页数统计依赖 | LibreOffice 转 PDF、文档属性读取、商业 Office 自动化 | 待确认 | +| 2 | 大文件处理模式 | 同步处理、后台任务、队列任务 | 待确认 | +| 3 | Markdown 渲染方案 | 前端引入 Markdown 渲染库,或后端转换为安全 HTML | 待确认 | + +### 10.4 数据定义疑问 + +| 序号 | 疑问内容 | 建议 | 状态 | +| --- | --- | --- | --- | +| 1 | 导出文件保存周期未明确 | Demo 阶段永久保存,正式版增加过期清理策略 | 待确认 | +| 2 | 原始上传文件是否长期保留 | 建议至少保留到关联审核流程结束 | 待确认 | +| 3 | 下载链接是否需要一次性或有效期控制 | 注册资料敏感,建议正式版增加有效期和权限校验 | 待确认 | + +--- + +## 十一、验收标准 + +| 序号 | 验收项 | 验收标准 | +| --- | --- | --- | +| 1 | 压缩包上传汇总 | 上传包含多层目录的压缩包后,系统能保留层级并统计支持文件页数 | +| 2 | 散装文件上传汇总 | 上传多个散装文件后,系统能生成同一批次汇总结果 | +| 3 | Markdown 展示 | AI 对话框能展示摘要表和文件简表,表格格式正常 | +| 4 | 下载链接 | AI 回复中提供 Markdown 报告和 Excel 明细下载链接,点击可下载 | +| 5 | 数据存档 | 数据库中能查询到批次记录、文件明细和导出文件记录 | +| 6 | 异常不中断 | 单个文件失败时不影响其他文件统计,失败原因可见 | +| 7 | 支持类型覆盖 | pdf、doc、docx、xls、xlsx、ppt、pptx 均能进入处理流程 | + +--- + +## 十二、下一步建议 + +1. 上传“文件目录参考附件”,补充标准目录样例和演示数据。 +2. 明确 Excel 页数统计口径,决定 Demo 版是否按工作表数量统计。 +3. 设计数据库表结构,包括汇总批次、文件明细、上传原始文件和导出文件。 +4. 改造 AI 对话框,增加附件上传、Markdown 渲染和下载链接展示能力。 +5. 实现文件扫描与页数统计服务,优先打通 pdf、docx、xlsx、pptx 的 Demo 流程。 diff --git a/docs/1.需求分析/2.NMPA注册资料法规核查与整改闭环.md b/docs/1.需求分析/2.NMPA注册资料法规核查与整改闭环.md new file mode 100644 index 0000000..6a180a3 --- /dev/null +++ b/docs/1.需求分析/2.NMPA注册资料法规核查与整改闭环.md @@ -0,0 +1,330 @@ +# NMPA 注册资料法规核查与整改闭环工作流需求分析 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 原始材料 | docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx | +| 法规资料来源 | docs/原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告 | +| 参考来源 URL | https://www.cmde.org.cn/xwdt/zxyw/20210930163300622.html | +| 备用法规入口 | https://www.nmpa.gov.cn/ | +| 工作流名称 | NMPA 注册资料法规核查与整改闭环工作流 | +| 分析日期 | 2026-06-05 | +| 分析版本 | V1.0 | + +--- + +## 一、需求背景 + +试剂盒及医疗器械 NMPA 注册申报资料准备过程中,注册人员不仅需要确认申报资料文件是否齐全,还需要进一步检查关键文件章节结构是否符合要求、不同文件中的核心信息是否一致,并对发现的问题形成风险预警和整改闭环。 + +原始任务中的第 2、4、5 条能力本质上属于同一条法规核查工作流: + +| 原任务编号 | 能力要求 | 在本工作流中的定位 | +| --- | --- | --- | +| 2 | 按照 NMPA 现行法规要求核查文件完整性 | 法规资料完整性核查 | +| 4 | 核查文档结构、信息一致性与章节规范性 | 章节结构核查与跨文件一致性核查 | +| 5 | 提供合规风险预警与处理建议 | 风险分级、整改建议、通知与复核闭环 | + +本工作流目标是:基于上一阶段生成的文件目录与页数汇总结果,结合本地法规资料包中的 CMDE 公告要求,对上传申报资料执行法规适用条件确认、资料完整性核查、章节结构核查、跨文件一致性核查,输出风险清单、法规核查矩阵、整改建议,并通过系统内提示和飞书通知责任人,支持补充资料后的复核与关闭。 + +--- + +## 二、需求范围 + +### 2.1 本期范围 + +| 序号 | 范围项 | 说明 | +| --- | --- | --- | +| 1 | 法规资料来源 | Demo 阶段暂按本地 CMDE 公告资料包执行,不做实时网页检索 | +| 2 | 适用品类 | 建立医疗器械/体外诊断试剂通用核查框架,优先覆盖体外诊断试剂注册申报资料 | +| 3 | 产品信息提取 | 从上传资料中提取产品类别、注册类型、临床评价路径、产品名称、预期用途、检测靶标、样本类型、储存条件等信息 | +| 4 | 适用条件确认 | 系统先自动提取适用条件,再由用户确认后执行完整核查 | +| 5 | 法规资料完整性核查 | 对照法规清单检查必需文件、条件性文件、建议文件是否存在 | +| 6 | 文件项级完整性核查 | 不仅判断文件类别是否存在,还检查关键子项、章节或字段是否齐全 | +| 7 | 章节结构核查 | 检查产品技术要求、说明书、检验报告等关键文件是否包含法规要求章节 | +| 8 | 跨文件一致性核查 | 检查产品名称、型号规格、适用范围、样本类型等核心信息在不同文件中是否一致 | +| 9 | 风险分级 | 按阻断项、高风险、中风险、低风险、提示项五级统一排序 | +| 10 | 整改建议 | 先按规则模板生成标准建议,再由 AI 整理为易读表达 | +| 11 | 责任人通知 | 默认按上传人识别责任人;上传人是当前用户时飞书通知 @ 当前用户 | +| 12 | 整改复核 | 支持重新上传整包复核,也支持仅上传缺失文件合并到原批次后复核 | +| 13 | 输出留痕 | 输出对话框摘要、Markdown 报告、Excel 缺失清单、飞书通知记录 | + +### 2.2 非本期范围 + +| 序号 | 范围项 | 说明 | +| --- | --- | --- | +| 1 | 官网法规实时更新 | 本期不实时抓取 NMPA/CMDE 官网,后续可扩展为内置清单加定期更新 | +| 2 | 全品类法规覆盖 | 本期建立通用框架,但规则内容优先围绕体外诊断试剂注册资料 | +| 3 | 自动关闭所有风险 | 待确认项和复核通过关闭前需要人工确认 | +| 4 | 企业级责任矩阵 | 本期默认按上传人通知,后续再支持按项目、部门、文件类别分配责任人 | + +--- + +## 三、用户角色与使用场景 + +| 角色 | 诉求 | 典型场景 | +| --- | --- | --- | +| 注册人员 | 快速发现缺失资料、结构不规范和核心信息冲突 | 上传注册申报资料包后发起法规核查工作流 | +| 审核人员 | 获得可追溯的法规核查矩阵和风险清单 | 在正式申报前复核资料是否达到提交条件 | +| 资料上传人 | 接收缺失项和风险项通知,并完成补充整改 | 系统发现阻断项、高风险或中风险后通过飞书 @ 上传人 | +| 系统管理员 | 维护法规规则、通知配置和核查记录 | 查看核查批次、规则版本、飞书发送记录和复核状态 | + +--- + +## 四、工作流主流程 + +### 4.1 主流程 + +```text +用户在 AI 对话框上传申报资料 +-> 系统执行文件清单汇总与页数统计 +-> 用户发起“NMPA 注册资料法规核查与整改闭环”工作流 +-> 系统读取本地法规资料包和内置法规清单 +-> 系统从上传资料中提取产品类别、注册类型、临床评价路径和产品关键信息 +-> 系统在对话框展示适用条件识别结果,请用户确认或修正 +-> 用户确认适用条件 +-> 系统裁剪本次适用的法规核查清单 +-> 系统并行执行法规资料完整性核查、章节结构核查、跨文件一致性核查 +-> 系统汇总问题并按风险等级排序 +-> 系统生成整改建议、法规依据和证据说明 +-> 系统在对话框展示风险摘要 +-> 系统生成 Markdown 报告、Excel 缺失清单和飞书通知记录 +-> 对阻断项、高风险、中风险发送飞书通知并 @ 上传人 +-> 上传人补充资料后发起整包重核或缺失文件补传复核 +-> 系统复核整改项 +-> 待确认项和复核通过关闭前由用户人工确认 +-> 风险项关闭或驳回 +``` + +### 4.2 工作流节点 + +| 节点编码 | 节点名称 | 输入 | 输出 | +| --- | --- | --- | --- | +| inventory | 文件清单汇总 | 上传资料、目录扫描结果 | 文件清单、页数、解析状态 | +| info_extract | 产品信息提取 | 文件名、目录名、文档首页或前几页内容 | 产品类别、注册类型、临床评价路径、产品关键信息 | +| condition_confirm | 适用条件确认 | 系统提取结果 | 用户确认后的适用条件 | +| rule_scope | 法规清单裁剪 | 法规规则库、适用条件 | 本次适用的核查清单 | +| completeness_check | 法规资料完整性核查 | 文件清单、法规核查清单 | 缺失文件、条件性缺失、待确认项 | +| structure_check | 章节结构核查 | 关键文件文本、章节模板 | 缺失章节、异常章节、格式问题 | +| consistency_check | 跨文件一致性核查 | 关键字段抽取结果 | 信息冲突项、证据文件、冲突字段 | +| risk_assess | 风险分级与建议生成 | 所有核查问题 | 风险等级、法规依据、整改建议 | +| notify | 系统提示与飞书通知 | 风险清单、责任人 | 对话提示、飞书通知记录 | +| rectify_review | 整改复核 | 补充资料、原风险项 | 复核结论、状态变更 | + +--- + +## 五、核心核查规则 + +### 5.1 法规资料完整性核查 + +系统对照法规清单检查本次申报应提交的文件是否齐全。Demo 阶段法规依据暂按本地 CMDE 公告资料包维护,后续可扩展为可配置规则库。 + +| 检查项 | 检查说明 | +| --- | --- | +| 必需文件 | 缺失后直接影响申报资料完整性的文件,如注册申报资料基本要求、产品技术要求、说明书、注册检验报告等 | +| 条件性文件 | 是否必需取决于产品类别、注册类型、临床评价路径等适用条件 | +| 建议文件 | 有助于完善资料但不一定构成阻断的问题 | +| 文件项级子项 | 检查关键文件内部是否存在必需章节、附件、签章页、结论页或关键字段 | + +### 5.2 文件匹配规则 + +系统采用三层匹配识别文件是否满足法规文件项: + +| 匹配层级 | 说明 | 示例 | +| --- | --- | --- | +| 文件名匹配 | 根据文件名关键词识别文件类别 | 文件名包含“产品技术要求” | +| 目录名匹配 | 文件名不明确时参考所在目录 | 文件位于“注册检验/报告”目录 | +| 首页内容匹配 | 文件名和目录名不足以判断时读取首页或前几页文本辅助识别 | 首页标题包含“医疗器械注册检验报告” | + +匹配结果需要记录命中证据,包括命中的文件路径、关键词、页码或文本片段,便于人工复核。 + +### 5.3 章节结构核查 + +系统对关键文件执行章节结构检查,判断是否存在法规或模板要求的章节。 + +| 文件类别 | 章节核查示例 | +| --- | --- | +| 产品技术要求 | 检查性能指标、检验方法、术语、附录等章节 | +| 说明书 | 检查产品名称、包装规格、预期用途、检验原理、主要组成成分、储存条件、有效期、样本要求、检验方法、阳性判断值或参考区间等章节 | +| 注册检验报告 | 检查封面、样品信息、检验依据、检验项目、检验结论、签章页等内容 | +| 临床评价资料 | 检查临床评价路径、数据来源、评价结论、适用性说明等内容 | + +### 5.4 跨文件一致性核查 + +系统抽取不同文件中的核心字段并进行一致性比对。 + +| 字段 | 典型来源文件 | 不一致示例 | +| --- | --- | --- | +| 产品名称 | 申请表、说明书、产品技术要求、检验报告 | 说明书和检验报告中的产品名称不一致 | +| 型号规格 | 说明书、产品技术要求、检验报告 | 规格型号缺失或描述不一致 | +| 预期用途 | 说明书、临床评价资料、注册申报表 | 适用人群或检测目的描述冲突 | +| 检测靶标 | 说明书、产品技术要求、性能研究资料 | 靶标名称、缩写或组合不一致 | +| 样本类型 | 说明书、检验报告、性能研究资料 | 样本类型范围不一致 | +| 储存条件 | 说明书、标签、稳定性研究资料 | 温度条件或有效期描述不一致 | + +--- + +## 六、风险等级与通知策略 + +### 6.1 风险等级 + +| 风险等级 | 定义 | 示例 | +| --- | --- | --- | +| 阻断项 | 直接影响资料能否进入有效申报或审核的严重问题 | 缺少法规必需文件;关键文件损坏、加密或页数为 0;产品名称/型号规格在关键文件中严重冲突 | +| 高风险 | 可能导致注册审评补正或重大整改的问题 | 关键章节缺失;注册检验报告缺少结论页;临床评价路径资料不完整 | +| 中风险 | 需要补充说明或修改,但不一定阻断整体资料审核的问题 | 个别关键字段缺失;章节标题不规范;证据页码不明确 | +| 低风险 | 对申报完整性影响较小,但建议修正的问题 | 文件命名不规范;目录层级不清晰 | +| 提示项 | 系统无法充分判断或建议人工关注的问题 | 条件性文件是否适用待人工确认 | + +### 6.2 通知策略 + +| 风险等级 | 系统内提示 | 飞书通知 | +| --- | --- | --- | +| 阻断项 | 是 | 是,@ 上传人 | +| 高风险 | 是 | 是,@ 上传人 | +| 中风险 | 是 | 是,@ 上传人 | +| 低风险 | 是 | 否 | +| 提示项 | 是 | 否 | + +责任人识别规则:本期默认按上传人识别责任人;上传人是当前用户时,系统内提示归属当前用户,飞书通知中 @ 当前用户。后续可扩展为项目责任人表、文件类别责任矩阵或目录归属规则。 + +--- + +## 七、整改建议与闭环状态 + +### 7.1 整改建议生成 + +整改建议采用“规则模板 + AI 润色”的方式生成: + +| 步骤 | 说明 | +| --- | --- | +| 规则模板 | 根据问题类型、法规依据、风险等级生成标准整改动作 | +| AI 整理 | 将标准动作整理为适合对话框和飞书通知阅读的表达 | +| 人工可复核 | 输出中保留法规依据、证据文件、命中规则和建议动作 | + +示例: + +```text +问题:缺少注册检验报告。 +风险等级:阻断项。 +整改建议:请补充与本产品一致的注册检验报告,并确保报告包含样品信息、检验依据、检验项目、检验结论和签章页。 +责任人:上传人。 +通知方式:系统内提示 + 飞书 @ 上传人。 +``` + +### 7.2 问题状态流转 + +| 状态 | 含义 | 触发方式 | +| --- | --- | --- | +| 待确认 | 系统发现条件性问题或判断依据不足 | 系统自动生成,等待用户确认 | +| 待处理 | 问题已确认,需要补充或修改资料 | 用户确认或系统自动判定 | +| 已补充 | 责任人已上传补充资料或替换文件 | 用户上传资料后自动进入 | +| 复核通过 | 系统复核后判断问题已解决 | 系统自动判断,关闭前需人工确认 | +| 复核不通过 | 系统复核后判断问题仍存在 | 系统自动判断 | +| 已关闭 | 用户确认问题已解决并关闭 | 人工确认 | + +### 7.3 复核方式 + +| 复核方式 | 适用场景 | +| --- | --- | +| 重新上传整包复核 | 资料包整体结构有较大调整,或需要重新生成完整核查报告 | +| 仅上传缺失文件复核 | 只补充少量缺失文件或替换单个问题文件 | + +待确认项和复核通过关闭前需要人工确认;其他状态可由系统根据核查结果自动流转。 + +--- + +## 八、输出要求 + +### 8.1 AI 对话框摘要 + +对话框摘要应优先展示风险分布和需要处理的事项。 + +```markdown +已完成 NMPA 注册资料法规核查。 + +| 风险等级 | 数量 | +| --- | --- | +| 阻断项 | 2 | +| 高风险 | 3 | +| 中风险 | 5 | +| 低风险 | 4 | +| 提示项 | 2 | + +| 风险等级 | 问题 | 责任人 | 建议 | +| --- | --- | --- | --- | +| 阻断项 | 缺少注册检验报告 | 上传人 | 请补充注册检验报告并重新复核 | +| 高风险 | 说明书缺少储存条件章节 | 上传人 | 请补充储存条件和有效期说明 | + +[下载 Markdown 核查报告](download-url) +[下载 Excel 缺失清单](download-url) +``` + +### 8.2 Markdown 核查报告 + +Markdown 报告至少包含: + +| 模块 | 内容 | +| --- | --- | +| 核查概览 | 批次编号、上传人、法规资料来源、规则版本、核查时间 | +| 适用条件 | 产品类别、注册类型、临床评价路径、产品关键信息及用户确认记录 | +| 风险清单 | 风险等级、问题描述、法规依据、证据、责任人、整改建议、状态 | +| 法规核查矩阵 | 法规文件项、是否适用、应提交资料、匹配文件、缺失情况、结论 | +| 章节结构核查 | 文件类别、章节要求、识别章节、缺失章节、结论 | +| 一致性核查 | 字段名称、来源文件、字段值、冲突说明、结论 | +| 飞书通知记录 | 通知时间、通知对象、通知等级、发送状态 | +| 复核记录 | 补充资料、复核时间、复核结果、关闭确认人 | + +### 8.3 Excel 缺失清单 + +Excel 至少包含以下工作表: + +| 工作表 | 内容 | +| --- | --- | +| 风险清单 | 所有风险项、等级、责任人、状态、建议 | +| 法规核查矩阵 | 应有文件、实际匹配文件、缺失情况、法规依据 | +| 章节结构问题 | 缺失章节、异常章节、文件路径、页码或证据 | +| 一致性问题 | 冲突字段、来源文件、字段值、风险等级 | +| 通知记录 | 飞书发送对象、发送时间、发送状态、通知内容摘要 | + +--- + +## 九、非功能性需求 + +### 9.1 可追溯性 + +| 要求 | 说明 | +| --- | --- | +| 规则版本留痕 | 每次核查记录使用的法规资料来源和规则版本 | +| 证据留痕 | 每个问题记录命中文件、路径、页码或文本片段 | +| 通知留痕 | 飞书通知需要记录发送对象、发送状态、发送时间和内容摘要 | +| 状态留痕 | 问题状态变化需要记录操作人、操作时间和变化原因 | + +### 9.2 安全要求 + +| 要求 | 说明 | +| --- | --- | +| 文件访问控制 | 核查报告、Excel 清单和原始资料仅授权用户可访问 | +| 敏感信息保护 | 飞书通知只展示必要摘要,不直接暴露完整敏感文件内容 | +| 人工确认 | 条件适用性和风险关闭前需人工确认,避免系统误判 | + +### 9.3 性能要求 + +| 场景 | 要求 | +| --- | --- | +| 小批次资料 | 50 个文件以内应在 1 分钟内完成初步风险摘要 | +| 中等批次资料 | 200 个文件以内支持后台异步处理和进度提示 | +| 单文件解析失败 | 不阻断整个批次,记录异常并继续其他文件核查 | + +--- + +## 十、待后续确认事项 + +| 序号 | 待确认项 | 当前建议 | +| --- | --- | --- | +| 1 | 飞书集成方式 | Demo 阶段可使用飞书 CLI、Webhook 或类似命令行工具发送通知 | +| 2 | 当前用户与飞书账号映射 | 需要维护用户账号与飞书 open_id、手机号或邮箱的映射关系 | +| 3 | 法规清单结构 | 功能设计阶段需将本地 CMDE 公告拆解为结构化规则表 | +| 4 | 条件性文件适用规则 | 先由系统提取并让用户确认,后续逐步自动化 | +| 5 | 跨文件一致性字段范围 | Demo 阶段优先覆盖产品名称、型号规格、预期用途、样本类型、储存条件 | diff --git a/docs/1.需求分析/3.产品关键信息提取与申报文件自动填表.md b/docs/1.需求分析/3.产品关键信息提取与申报文件自动填表.md new file mode 100644 index 0000000..7bc7049 --- /dev/null +++ b/docs/1.需求分析/3.产品关键信息提取与申报文件自动填表.md @@ -0,0 +1,394 @@ +# 产品关键信息提取与申报文件自动填表需求分析 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 原始材料 | docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx | +| 法规模板来源 | docs/原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告 | +| 功能主题 | 从产品文件中提取关键信息并自动填写至指定申报模板 | +| 分析日期 | 2026-06-07 | +| 分析版本 | V1.0 | + +--- + +## 一、需求背景 + +试剂盒及体外诊断试剂注册申报过程中,注册人员需要将同一批产品关键信息重复填写到注册证格式文件、变更注册或备案文件、安全和性能基本原则清单等申报材料中。人工复制粘贴容易出现字段遗漏、表述不一致、来源不可追溯和模板误改等问题。 + +原始任务中的第 3 条能力要求系统能够“从产品文件中提取关键信息并自动填写至目标文件”。本功能目标是:系统基于用户上传的产品说明书、产品技术要求、检测报告、性能研究资料等文件,自动抽取产品名称、检测靶标、适用范围、储存条件、性能指标等核心信息,复制指定法规模板生成可填写副本,将抽取结果写入模板,并输出 Word 与 PDF 两种下载文件。 + +本功能是前两批能力的后续增强:依赖第一批文件汇总结果定位产品文件,复用第二批文本抽取、适用条件确认和一致性核查能力,同时新增“模板识别、字段映射、模板填充、冲突高亮、PDF 转换、来源追溯”能力。 + +--- + +## 二、需求范围 + +### 2.1 本期范围 + +| 序号 | 范围项 | 说明 | +| --- | --- | --- | +| 1 | 目标模板复制 | 从原始法规资料中复制指定模板,不覆盖原始文件 | +| 2 | 注册类型选择 | 首次注册填写注册证格式;变更注册或备案填写变更注册(备案)文件格式 | +| 3 | 安全和性能基本原则清单填写 | 无论首次注册或变更注册,均生成并填写安全和性能基本原则清单 | +| 4 | 产品信息提取 | 从产品说明书、产品技术要求、检测报告、性能研究资料等文件中抽取模板所需字段 | +| 5 | 模板字段识别 | 读取目标模板中的表格、段落、占位栏位和清单条目,建立字段映射 | +| 6 | 自动填入模板 | 将抽取字段写入模板副本,缺失字段保持留空 | +| 7 | 冲突标记 | 同一字段在多个文件中不一致时,按说明书为准填写,并在模板中黄色底色、红色字体标记 | +| 8 | 冲突摘要展示 | AI 对话框展示冲突字段、采用值、冲突来源和待用户下载确认提示 | +| 9 | Word 导出 | 输出填好的 `.docx` 或可编辑 Word 文件 | +| 10 | PDF 导出 | 将填好的 Word 转换为 PDF,尽量保持原 Word 模板版式一致,可用于正式提交前预览 | +| 11 | 来源追溯 | 允许额外输出字段来源追溯清单,记录字段来源文件、文本片段、冲突状态和填入目标 | + +### 2.2 非本期范围 + +| 序号 | 范围项 | 说明 | +| --- | --- | --- | +| 1 | 直接覆盖原始法规模板 | 原始材料只作为模板来源,不允许被改写 | +| 2 | 自动代替人工最终确认 | 系统生成带标记文件,用户自行下载核对确认 | +| 3 | 在线提交 NMPA 系统 | 本期只生成申报文件,不对接外部申报系统 | +| 4 | 全部法规表单覆盖 | 本期仅覆盖用户指定的三个目标模板 | +| 5 | 复杂版式人工校订 | 系统尽量保持模板版式,复杂错位仍需人工最终复核 | + +--- + +## 三、目标模板 + +本期一共处理三个目标模板。用户此前重复提到“体外诊断试剂安全和性能基本原则清单”,经确认属于误填,实际只有一个该清单模板。 + +| 序号 | 模板名称 | 原始文件 | 使用条件 | 输出要求 | +| --- | --- | --- | --- | --- | +| 1 | 中华人民共和国医疗器械注册证(体外诊断试剂)(格式) | 中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx | 首次注册 | Word + PDF | +| 2 | 中华人民共和国医疗器械变更注册(备案)文件(体外诊断试剂)(格式) | 中华人民共和国医疗器械变更注册(备案)文件(体外诊断试剂)(格式).doc | 变更注册或备案 | Word + PDF | +| 3 | 体外诊断试剂安全和性能基本原则清单 | 体外诊断试剂安全和性能基本原则清单.doc | 首次注册、变更注册、备案均适用 | Word + PDF | + +### 3.1 已识别注册证模板字段 + +从 `中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx` 中已识别到以下表格栏目: + +| 字段 | 填写规则 | +| --- | --- | +| 注册人名称 | 从申请人、注册人、企业信息类文件中抽取 | +| 注册人住所 | 从申请人、注册人、企业信息类文件中抽取 | +| 生产地址 | 从注册资料、说明书、质量体系或生产信息文件中抽取 | +| 代理人名称 | 进口体外诊断试剂适用,境内产品可留空 | +| 代理人住所 | 进口体外诊断试剂适用,境内产品可留空 | +| 产品名称 | 优先取说明书字段 | +| 包装规格 | 对应型号规格、包装规格 | +| 主要组成成分 | 优先取说明书和产品技术要求 | +| 预期用途 | 对应适用范围、预期用途 | +| 产品储存条件及有效期 | 对应储存条件、有效期 | +| 附件 | 默认包含产品技术要求、说明书,可根据实际文件匹配补充 | +| 其他内容 | 未识别或需人工确认时留空 | +| 备注 | 未识别或需人工确认时留空 | + +### 3.2 模板解析约束 + +变更注册(备案)文件格式和安全和性能基本原则清单当前为 `.doc` 格式。系统实施时需要支持以下任一方案: + +| 方案 | 说明 | +| --- | --- | +| LibreOffice 转换 | 使用 LibreOffice/soffice 将 `.doc` 转为 `.docx` 后识别和填写 | +| 预转换模板 | 项目内预先保存经人工确认的 `.docx` 模板副本 | +| OOXML/COM 方案 | 在 Windows 环境通过 Office 自动化读取和转换模板 | + +无论采用哪种方式,转换后的模板必须保留原文件表格结构、分页、字体和版式,PDF 导出需以填好的 Word 为来源。 + +--- + +## 四、用户角色与使用场景 + +| 角色 | 诉求 | 典型场景 | +| --- | --- | --- | +| 注册人员 | 减少重复填表,提高字段一致性 | 上传注册资料包后生成已填注册证格式和基本原则清单 | +| 变更注册负责人 | 根据变更类型生成变更注册或备案文件 | 上传变更资料后生成已填变更注册(备案)文件 | +| 审核人员 | 快速定位字段来源和冲突 | 下载带冲突高亮的 Word/PDF,并查看 AI 对话框冲突摘要 | +| 系统管理员 | 维护模板版本和转换能力 | 更新法规模板、检查 PDF 转换服务和导出记录 | + +--- + +## 五、业务流程分析 + +### 5.1 主流程 + +```text +用户上传产品注册资料 +-> 系统执行文件目录与页数汇总 +-> 系统执行法规核查前置文本抽取 +-> 系统识别注册类型:首次注册、变更注册或备案 +-> 系统选择本次适用目标模板 +-> 系统复制原始模板到批次工作目录 +-> 系统读取目标模板栏目和清单条目 +-> 系统从产品文件中抽取模板所需字段 +-> 系统按字段优先级合并抽取结果 +-> 如字段存在跨文件冲突,系统按说明书为准填入,并做黄色底色、红色字体标记 +-> 缺失字段保持留空 +-> 系统逐条判断安全和性能基本原则清单的适用性、符合性证据和证明文件位置 +-> 系统生成已填 Word 文件 +-> 系统将已填 Word 转换为 PDF +-> 系统生成来源追溯清单 +-> AI 对话框展示生成结果、冲突字段摘要和下载链接 +-> 用户下载 Word/PDF 自行确认 +``` + +### 5.2 注册类型分支 + +| 注册类型 | 生成文件 | +| --- | --- | +| 首次注册 | 注册证格式 Word/PDF;安全和性能基本原则清单 Word/PDF | +| 变更注册 | 变更注册(备案)文件 Word/PDF;安全和性能基本原则清单 Word/PDF | +| 备案 | 变更注册(备案)文件 Word/PDF;安全和性能基本原则清单 Word/PDF | +| 注册类型无法识别 | AI 对话框提示待确认;默认不生成注册证或变更文件,只可生成带待确认标记的草稿版本 | + +### 5.3 异常流程 + +| 异常场景 | 处理方式 | +| --- | --- | +| 模板文件不存在 | 批次标记失败,对话框提示缺少目标模板 | +| `.doc` 模板无法转换 | 对应模板导出失败,其他模板继续生成 | +| 字段未提取到 | 目标栏位留空,来源追溯清单记录为空 | +| 字段冲突 | 按说明书为准填入,模板内高亮标记,对话框展示冲突摘要 | +| PDF 转换失败 | 保留 Word 下载,提示 PDF 生成失败原因 | +| 模板版式明显错位 | 标记为需人工复核,不阻断 Word 文件下载 | + +--- + +## 六、信息提取与字段规则 + +### 6.1 字段范围 + +字段范围不固定写死,应以三个目标模板的实际栏目和清单条目为准动态建立。Demo 阶段优先覆盖以下字段: + +| 字段 | 说明 | +| --- | --- | +| 产品名称 | 产品标准名称 | +| 检测靶标 | 被检测物、基因、抗原、抗体、病原体或生物标志物 | +| 适用范围/预期用途 | 适用人群、样本类型、检测目的、临床用途 | +| 储存条件 | 温度、避光、防潮等保存条件 | +| 性能指标 | 分析灵敏度、特异性、重复性、准确度、检出限等 | +| 型号规格/包装规格 | 规格型号、包装规格、人份数或测试数 | +| 样本类型 | 血清、血浆、全血、咽拭子等 | +| 有效期 | 产品有效期或稳定性期限 | +| 主要组成成分 | 试剂、校准品、质控品、耗材等组成 | +| 检验原理 | 反应原理、方法学或检测平台 | +| 注册人/申请人 | 注册申请主体 | +| 生产地址 | 生产场所地址 | + +### 6.2 来源文件优先级 + +| 优先级 | 文件类型 | 说明 | +| --- | --- | --- | +| 1 | 说明书 | 字段冲突时默认以说明书为准 | +| 2 | 产品技术要求 | 用于补充性能指标、检验方法、组成成分等字段 | +| 3 | 注册检验报告/检测报告 | 用于补充性能指标、样本信息、检验依据和结论 | +| 4 | 性能研究资料 | 用于补充安全和性能基本原则清单证据 | +| 5 | 其他注册资料 | 用于补充申请人、生产地址、附件清单等信息 | + +### 6.3 冲突处理规则 + +| 场景 | 处理方式 | +| --- | --- | +| 说明书与其他文件字段不一致 | 按说明书值填入模板 | +| 多个非说明书文件不一致,说明书缺失 | 目标字段留空或取最高优先级来源,具体规则由实现阶段配置 | +| 字段被高亮标记 | 黄色底色、红色字体,提示用户下载后确认 | +| AI 对话框展示 | 展示字段名、采用值、冲突值、来源文件和目标模板 | + +--- + +## 七、安全和性能基本原则清单填写规则 + +安全和性能基本原则清单不只填写基础产品信息,还需要根据产品文件内容逐条判断清单条目的适用性、符合性证据和证明文件位置。 + +| 填写项 | 规则 | +| --- | --- | +| 适用/不适用 | 根据产品特性、检测方法、样本类型、是否含仪器/软件/灭菌/生物材料等信息判断 | +| 符合性说明 | 从产品技术要求、说明书、风险管理、性能研究、稳定性研究等文件中提取证据摘要 | +| 证明文件位置 | 填写证据文件名、章节、页码或可定位文本片段 | +| 无法判断 | 留空或标记待人工确认,来源追溯清单记录原因 | +| 冲突证据 | 如不同文件对同一条款适用性或证据描述冲突,保留高亮并在对话框列出 | + +逐条判断结果需要可追溯,不能只输出“适用”或“不适用”结论。 + +--- + +## 八、输出要求 + +### 8.1 文件命名 + +文件命名规则: + +```text +批次号-产品名称-注册证格式.docx +批次号-产品名称-注册证格式.pdf +批次号-产品名称-变更注册备案文件.docx +批次号-产品名称-变更注册备案文件.pdf +批次号-产品名称-安全和性能基本原则清单.docx +批次号-产品名称-安全和性能基本原则清单.pdf +批次号-产品名称-字段来源追溯清单.xlsx +``` + +产品名称为空时,可使用 `未识别产品名称` 作为文件名占位。 + +### 8.2 AI 对话框摘要 + +AI 对话框应展示生成结果、下载链接和冲突字段摘要。 + +```markdown +已生成申报模板自动填表文件。 + +| 文件 | Word | PDF | +| --- | --- | --- | +| 注册证格式 | 下载 | 下载 | +| 安全和性能基本原则清单 | 下载 | 下载 | + +| 冲突字段 | 采用值 | 冲突来源 | 处理 | +| --- | --- | --- | --- | +| 储存条件 | 2-8℃保存 | 产品技术要求:-20℃保存 | 已按说明书填入,并在模板中高亮 | +``` + +### 8.3 Word 输出 + +| 要求 | 说明 | +| --- | --- | +| 模板副本 | 从原始法规模板复制生成,不覆盖原始文件 | +| 版式保持 | 保留原模板表格、段落、分页、字体和标题结构 | +| 冲突高亮 | 黄色底色、红色字体 | +| 缺失字段 | 留空,不填“待补充” | +| 可编辑 | 用户可下载后继续人工修改 | + +### 8.4 PDF 输出 + +| 要求 | 说明 | +| --- | --- | +| 来源 | 由填好的 Word 转换生成 | +| 版式 | 尽量与原 Word 模板一致 | +| 用途 | 可作为正式提交前预览 | +| 失败处理 | PDF 失败不影响 Word 下载 | + +### 8.5 来源追溯清单 + +来源追溯清单允许额外生成,建议至少包含: + +| 字段 | 说明 | +| --- | --- | +| 目标模板 | 字段填入哪个模板 | +| 目标栏位/条目 | 字段对应的表格栏位或清单条目 | +| 填入值 | 实际写入模板的值 | +| 来源文件 | 取值来源文件 | +| 来源片段 | 支撑取值的文本片段 | +| 是否冲突 | 是/否 | +| 冲突值 | 其他文件中的不同值 | +| 处理方式 | 采用说明书、留空、高亮、待人工确认等 | + +--- + +## 九、功能模块梳理 + +| 序号 | 功能名称 | 功能描述 | 优先级 | +| --- | --- | --- | --- | +| 1 | 模板管理 | 维护三个目标模板路径、版本和适用注册类型 | P0 | +| 2 | 模板副本生成 | 将原始模板复制到批次工作目录 | P0 | +| 3 | 模板结构识别 | 识别模板中的表格字段、段落占位、清单条目 | P0 | +| 4 | 产品字段抽取 | 从上传文件中抽取模板所需产品字段 | P0 | +| 5 | 字段合并与冲突检测 | 按说明书优先级合并字段,并识别跨文件冲突 | P0 | +| 6 | Word 模板填充 | 将字段写入 Word 模板副本 | P0 | +| 7 | 冲突高亮 | 对冲突字段应用黄色底色和红色字体 | P0 | +| 8 | 基本原则逐条判断 | 判断安全和性能条目的适用性、符合性证据和证明文件位置 | P0 | +| 9 | PDF 转换 | 将填好的 Word 转为 PDF | P0 | +| 10 | 下载链接生成 | 在 AI 对话框提供 Word/PDF 下载链接 | P0 | +| 11 | 来源追溯清单导出 | 输出字段来源、冲突和填入目标 | P1 | +| 12 | 版式 QA | 对 Word/PDF 版式进行自动或人工可见检查 | P1 | + +--- + +## 十、数据实体分析 + +| 实体名称 | 字段说明 | 关联实体 | +| --- | --- | --- | +| 自动填表批次 | 批次编号、用户、会话、注册类型、产品名称、状态、错误信息、创建时间、完成时间 | 文件汇总批次、法规核查批次 | +| 模板副本 | 模板名称、模板类型、原始模板路径、副本路径、模板版本、适用条件 | 自动填表批次 | +| 提取字段 | 字段名、填入值、来源文件、来源片段、来源优先级、是否冲突、冲突详情 | 自动填表批次 | +| 填表结果文件 | 文件类型、文件名、Word 路径、PDF 路径、下载状态 | 自动填表批次 | +| 清单条目判断 | 条目编号、条目内容、适用性、符合性证据、证明文件位置、判断来源 | 自动填表批次 | + +--- + +## 十一、非功能性需求 + +### 11.1 可追溯性 + +| 要求 | 说明 | +| --- | --- | +| 字段来源可追溯 | 每个填入字段应能追溯到来源文件和文本片段 | +| 模板版本可追溯 | 每次生成记录原始模板文件名、版本和路径 | +| 冲突处理可追溯 | 冲突字段记录采用值、冲突值和处理规则 | +| 输出文件可追溯 | Word/PDF 文件关联批次、用户和会话 | + +### 11.2 安全要求 + +| 要求 | 说明 | +| --- | --- | +| 原始模板保护 | 不允许覆盖或修改原始法规资料目录中的模板 | +| 下载权限 | Word/PDF/追溯清单仅允许当前会话授权用户下载 | +| 敏感信息保护 | 对话框只展示必要冲突摘要,不展示大段敏感原文 | +| 文件隔离 | 不同用户、不同批次的模板副本和导出文件隔离存储 | + +### 11.3 版式要求 + +| 要求 | 说明 | +| --- | --- | +| Word 版式 | 尽量保持原模板表格、字体、分页和段落结构 | +| PDF 版式 | 与填好后的 Word 一致,可用于正式提交前预览 | +| 高亮可见 | 冲突字段在 Word 和 PDF 中均应能被用户识别 | +| 缺失字段不污染模板 | 未提取字段留空,不填入系统提示语 | + +### 11.4 性能要求 + +| 场景 | 要求 | +| --- | --- | +| 小批次资料 | 50 个文件以内,应在 1 分钟内完成字段抽取和模板生成 | +| 中等批次资料 | 200 个文件以内支持后台异步处理和进度提示 | +| 单个模板失败 | 不影响其他适用模板生成 | +| 单个字段失败 | 不影响整份模板生成,字段留空并记录原因 | + +--- + +## 十二、待后续确认事项 + +| 序号 | 待确认项 | 当前建议 | +| --- | --- | --- | +| 1 | `.doc` 模板转换方案 | 优先使用 LibreOffice/soffice 转 docx;无法部署时预置人工确认版 docx 模板 | +| 2 | 变更注册(备案)文件字段清单 | 需在模板可解析后补充字段映射 | +| 3 | 安全和性能基本原则清单条目结构 | 需在模板可解析后拆解条目编号、要求、适用性和证据栏 | +| 4 | 说明书识别规则 | 需明确如何从上传资料中判定哪份文件是说明书 | +| 5 | PDF 转换质量标准 | 需明确是否要求逐页渲染检查、页数一致和关键表格不跨页错位 | +| 6 | 注册类型无法识别时是否允许生成草稿 | 建议允许生成安全和性能基本原则清单,注册证或变更文件等待确认 | + +--- + +## 十三、验收标准 + +| 序号 | 验收项 | 验收标准 | +| --- | --- | --- | +| 1 | 模板复制 | 系统生成模板副本,不修改原始法规模板 | +| 2 | 首次注册文件选择 | 首次注册场景生成注册证格式和安全和性能基本原则清单 | +| 3 | 变更注册/备案文件选择 | 变更注册或备案场景生成变更注册(备案)文件和安全和性能基本原则清单 | +| 4 | 字段自动填写 | 产品名称、预期用途、储存条件、包装规格等字段能自动写入对应栏目 | +| 5 | 缺失字段留空 | 未提取到的字段保持空白 | +| 6 | 冲突字段高亮 | 字段冲突时按说明书值填入,并在 Word/PDF 中黄色底色、红色字体标记 | +| 7 | 冲突摘要展示 | AI 对话框展示冲突字段、采用值、冲突来源和处理方式 | +| 8 | 基本原则清单判断 | 系统能逐条输出适用/不适用、符合性证据和证明文件位置 | +| 9 | Word 下载 | 对话框提供填好后的 Word 下载链接 | +| 10 | PDF 下载 | 对话框提供由 Word 转换生成的 PDF 下载链接 | +| 11 | 来源追溯 | 可导出字段来源追溯清单,记录字段来源和冲突情况 | +| 12 | 异常不中断 | 单个字段、单个模板或 PDF 转换失败时,其他结果仍可正常输出 | + +--- + +## 十四、下一步建议 + +1. 将两个 `.doc` 原始模板转换为可解析的 `.docx` 工作模板,并人工确认版式无明显变化。 +2. 拆解三个模板的字段、表格和清单条目,形成模板字段映射配置。 +3. 扩展产品信息抽取字段,优先覆盖注册证模板已识别字段和安全和性能基本原则清单证据字段。 +4. 设计冲突高亮写入规则,确保 Word 与 PDF 中均可见。 +5. 接入 Word 到 PDF 转换能力,并建立页数、版式和关键表格的转换质量检查。 diff --git a/docs/1.需求分析/4.飞书通知与问答接入.md b/docs/1.需求分析/4.飞书通知与问答接入.md new file mode 100644 index 0000000..8af03f6 --- /dev/null +++ b/docs/1.需求分析/4.飞书通知与问答接入.md @@ -0,0 +1,527 @@ +# 飞书通知与问答接入需求分析 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 功能主题 | 飞书通知链路与飞书内问答能力接入 | +| 关联工作流 | 自动汇总、NMPA 注册资料法规核查与整改闭环、产品关键信息提取与申报文件自动填表 | +| 分析日期 | 2026-06-07 | +| 分析版本 | V1.0 | +| 短期目标 | 流程结束后同步飞书提醒 | +| 终极目标 | 用户可在飞书内向 Agent 提问并获得基于系统数据的回答 | + +--- + +## 一、需求背景 + +当前系统已经具备多个注册资料处理工作流,包括文件自动汇总、法规核查与整改闭环、产品关键信息提取与申报文件自动填表。系统内已经存在模拟通知记录和通知节点,但尚未接入真实飞书发送链路。 + +在实际业务协作中,注册人员、审核人员和整改负责人往往以飞书群或飞书私聊作为日常沟通入口。如果工作流只在系统页面内展示结果,用户需要主动返回系统查看状态,容易造成流程完成后无人跟进、整改项遗漏、生成文件未及时下载等问题。 + +因此需要引入飞书接入能力,分阶段实现: + +1. 流程结束后自动向飞书发送提醒,完成从“系统内闭环”到“协作通知闭环”的升级。 +2. 后续支持用户在飞书内与 Agent 对话,查询批次状态、风险项、生成文件、整改建议等信息。 + +--- + +## 二、接入方案调研摘要 + +### 2.1 主方案:飞书官方智能体/应用机器人 + 消息 API + +飞书开放平台支持创建飞书智能体应用或企业自建应用机器人。系统通过 `App ID` 和 `App Secret` 获取 `tenant_access_token`,再调用飞书消息 API 向固定群发送流程完成提醒;后续通过事件订阅接收用户私聊机器人或群内 @ 机器人的消息,实现飞书内问答。 + +该方案同时覆盖短期“流程结束后提醒”和终极“飞书内问答”,避免先接自定义 Webhook、后续再迁移到应用机器人的重复建设。 + +核心能力: + +| 能力 | 用途 | +| --- | --- | +| 飞书智能体/应用机器人 | 允许 Agent 以机器人身份进入飞书 | +| tenant_access_token | 使用 App ID、App Secret 换取应用访问令牌 | +| 发送消息 API | 主动向用户或群聊发送文本、富文本、卡片、文件等消息 | +| 事件订阅 | 接收用户私聊机器人或群里 @ 机器人的消息 | +| 权限配置 | 申请发送消息、接收消息、读取用户或群组信息等权限 | + +### 2.2 备选方案:飞书自定义机器人 Webhook + +飞书自定义机器人 Webhook 适合只做固定群主动推送,但不适合飞书内问答、私聊回复和统一身份权限管理。本项目不将 Webhook 作为主接入方案,仅作为后续极简部署或故障降级备选。 + +### 2.3 参考官方文档 + +| 主题 | 参考地址 | +| --- | --- | +| 一键创建飞书智能体应用 | https://open.feishu.cn/document/mcp_open_tools/integrating-agents-with-feishu/overview | +| 机器人概述 | https://open.feishu.cn/document/client-docs/bot-v3/bot-overview?lang=zh-CN | +| 自建应用获取 tenant_access_token | https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal?lang=zh-CN | +| 发送消息 API | https://open.feishu.cn/document/server-docs/im-v1/message/create?lang=zh-CN | +| 事件订阅概述 | https://open.feishu.cn/document/server-docs/event-subscription-guide/overview?lang=zh-CN | + +--- + +## 三、总体目标 + +### 3.1 短期目标:流程结束后同步提醒到指定个人账号 + +当系统中的工作流执行结束后,自动通过飞书智能体向指定个人账号发送一条结构化私聊提醒。Demo 阶段先与当前系统负责人账号单独对接,不接入外部群聊。提醒内容应帮助用户快速判断: + +| 信息项 | 说明 | +| --- | --- | +| 哪个流程完成 | 例如自动汇总、法规核查、自动填表 | +| 哪个批次完成 | 展示批次编号、会话标题或上传文件摘要 | +| 当前状态 | 成功、部分成功、失败、需人工确认 | +| 核心结果 | 风险数量、阻断项数量、生成文件数量、冲突字段数量等 | +| 下一步动作 | 查看报告、下载文件、处理整改项、回到系统确认 | +| 系统入口 | 提供可点击链接,跳转到对应批次或会话页面 | +| 被提醒人 | 首期固定发送给已配置的个人飞书账号 | + +### 3.2 中期目标:按流程和责任人分发 + +在个人账号通知跑通后,逐步支持更精细的通知策略: + +| 通知策略 | 说明 | +| --- | --- | +| 按发起人私聊 | 根据系统用户映射发送给流程发起人 | +| 按工作流分群 | 不同工作流通知到不同群 | +| 按项目分群 | 同一项目或产品线通知到指定群 | +| 按负责人私聊 | 将待处理事项发送给上传人、审核人或整改负责人 | +| 风险分级通知 | 阻断项和高风险立即通知,低风险可汇总通知 | + +### 3.3 终极目标:飞书内问答 + +用户可以在飞书内向 Agent 提问,系统根据用户消息识别意图,查询本地业务数据和已生成结果,返回回答。 + +示例问题: + +| 问题 | 预期回答 | +| --- | --- | +| “最近一个法规核查批次结果怎么样?” | 返回最近批次状态、风险数量和报告入口 | +| “RR-20260607-001 有哪些阻断项?” | 返回阻断项标题、法规依据、整改建议 | +| “自动填表生成的 Word 在哪里?” | 返回生成文件列表和下载入口 | +| “这个批次还缺哪些资料?” | 返回缺失文件清单和对应建议 | +| “帮我解释第 3 个风险项” | 返回风险说明、证据文件、整改建议和注意事项 | + +--- + +## 四、需求范围 + +### 4.1 本期范围 + +| 序号 | 范围项 | 说明 | +| --- | --- | --- | +| 1 | 真实飞书通知通道 | 接入飞书官方智能体/应用机器人消息 API | +| 2 | 通知开关 | 通过环境变量控制是否启用真实飞书通知 | +| 3 | 保留 mock 通道 | 默认可回退到 mock,不影响本地开发和自动化测试 | +| 4 | 工作流完成通知 | 流程成功、部分成功或失败后发送飞书提醒 | +| 5 | 通知记录落库 | 记录通道、目标、发送状态、发送时间、错误信息和原始 payload | +| 6 | 失败不阻断主流程 | 飞书发送失败只记录错误,不让业务工作流失败,首期不自动重试 | +| 7 | 消息模板 | 输出清晰的富文本消息,包含批次、状态、摘要和系统链接 | +| 8 | 安全配置 | App ID、App Secret、事件订阅密钥等敏感配置不得写入代码库 | +| 9 | 基础测试 | 覆盖成功、失败、未启用、配置缺失、发送超时等场景 | + +### 4.2 非本期范围 + +| 序号 | 范围项 | 说明 | +| --- | --- | --- | +| 1 | 飞书内问答完整实现 | 本期只为后续问答预留架构,不直接实现复杂对话 | +| 2 | 飞书审批流 | 不接入飞书审批或表单能力 | +| 3 | 飞书文档写入 | 不自动创建或更新飞书文档 | +| 4 | 企业级组织架构同步 | 不做通讯录全量同步 | +| 5 | 多租户飞书应用管理 | Demo 阶段只考虑单企业或单环境配置 | +| 6 | 复杂交互式卡片操作 | 本期优先文本或简单卡片,不实现按钮回调闭环 | + +--- + +## 五、用户角色与使用场景 + +| 角色 | 诉求 | 典型场景 | +| --- | --- | --- | +| 注册人员 | 及时知道批次完成并下载结果 | 自动汇总或自动填表完成后在飞书收到提醒 | +| 审核人员 | 快速查看法规核查风险摘要 | 法规核查结束后查看阻断项和高风险数量 | +| 整改负责人 | 及时处理缺失资料和风险项 | 飞书提醒中看到整改入口和主要问题 | +| 系统管理员 | 维护通知配置并排查发送失败 | 查看通知记录、错误信息和配置状态 | +| 后续飞书用户 | 不打开系统也能查询结果 | 在飞书中向机器人提问批次状态或风险项 | + +--- + +## 六、业务流程 + +### 6.1 短期通知流程 + +```text +用户发起业务工作流 +-> 系统执行自动汇总、法规核查或自动填表 +-> 工作流进入完成、部分成功或失败状态 +-> 系统生成通知摘要 +-> 系统判断飞书真实通知是否启用 +-> 未启用:写入 mock 通知记录 +-> 已启用:使用 App ID/App Secret 获取或复用 tenant_access_token +-> 调用飞书消息 API 向指定个人账号发送富文本消息 +-> 发送成功:写入成功通知记录和 sent_at +-> 发送失败:写入失败通知记录、错误信息和重试次数 +-> 主工作流继续完成,不因通知失败回滚业务结果 +``` + +### 6.2 终极问答流程 + +```text +用户在飞书私聊机器人或群里 @ 机器人提问 +-> 飞书通过事件订阅将消息推送到系统回调地址 +-> 系统校验事件来源和签名 +-> 系统解析用户身份、会话位置和消息内容 +-> 系统执行意图识别 +-> 系统根据意图查询批次、文件、报告、风险项或生成结果 +-> 系统组织回答内容 +-> 系统通过飞书消息 API 回复用户或群聊 +-> 系统记录问答日志、引用数据和错误信息 +``` + +--- + +## 七、功能需求 + +### 7.1 通知触发点 + +| 工作流 | 触发节点 | 通知时机 | 初始优先级 | +| --- | --- | --- | --- | +| 自动汇总 | 文件汇总完成 | 成功、部分成功、失败 | 高 | +| 法规核查与整改闭环 | 风险分级和报告生成后 | 成功、部分成功、失败;阻断项和高风险优先展示 | 高 | +| 自动填表 | Word/PDF 和追溯清单生成后 | 成功、部分成功、失败 | 高 | + +首期三个业务工作流均接入飞书通知:自动汇总、法规核查与整改闭环、产品关键信息提取与申报文件自动填表。 + +### 7.2 通知内容模板 + +通知消息首期采用富文本格式,需支持换行和重点信息突出展示。通知消息应至少包含: + +| 字段 | 说明 | +| --- | --- | +| 标题 | 工作流名称 + 状态 | +| 批次编号 | 例如 RR-NOTIFY、AFF-NOTIFY | +| 发起人 | 当前系统用户 | +| 完成时间 | 工作流完成时间 | +| 结果摘要 | 风险数量、文件数量、导出文件数量、冲突字段数量等 | +| 下一步 | 查看报告、下载结果、处理整改项、重新复核 | +| 系统链接 | 首期使用本地地址拼接系统内批次或会话页面链接,例如 `http://127.0.0.1:8000/...` | + +发送策略: + +| 策略项 | 要求 | +| --- | --- | +| 通知状态 | 成功、部分成功、失败均发送飞书通知 | +| 重复发送 | 同一批次、同一工作流、同一状态只发送一次,避免重复点击或重复运行造成刷屏 | +| 失败重试 | 首期不自动重试,只记录失败状态和错误信息 | +| 主流程影响 | 通知失败不阻断业务工作流完成 | + +消息内容粒度: + +| 粒度项 | 要求 | +| --- | --- | +| 基础信息 | 工作流名称、状态、批次编号、发起人、完成时间 | +| 结果摘要 | 自动汇总展示文件数和异常数;法规核查展示风险总数、阻断项、高风险数量;自动填表展示导出文件数、冲突字段数和失败原因概述 | +| 详细清单 | 首期不在飞书私聊中展开完整风险项、缺失项或文件明细,避免消息过长 | +| 系统入口 | 首期使用本地地址拼接系统内批次或会话链接,部署后再升级为可配置外部域名 | + +### 7.3 通知状态记录 + +通知发送后必须落库,便于排查和审计。 + +| 字段 | 说明 | +| --- | --- | +| channel | mock、feishu_api 等 | +| target | 指定个人 open_id、user_id 等 | +| status | pending、sent、failed 或 success、failed | +| payload | 发送内容摘要和业务上下文 | +| external_message_id | 飞书返回的消息 ID,Webhook 无返回时可为空 | +| error_message | 失败原因 | +| retry_count | 重试次数 | +| sent_at | 成功发送时间 | + +### 7.4 配置需求 + +环境变量建议: + +| 配置项 | 说明 | +| --- | --- | +| FEISHU_NOTIFY_ENABLED | 是否启用真实飞书通知 | +| FEISHU_NOTIFY_CHANNEL | 通知通道,首期为 feishu_api | +| FEISHU_APP_ID | 飞书智能体/企业自建应用 App ID | +| FEISHU_APP_SECRET | 飞书智能体/企业自建应用 App Secret | +| FEISHU_DEFAULT_USER_OPEN_ID | 首期指定接收人的飞书 open_id | +| FEISHU_DEFAULT_USER_ID | 首期指定接收人的飞书 user_id,可作为 open_id 的备选 | +| FEISHU_DEFAULT_TARGET_NAME | 默认通知目标名称,用于记录展示 | +| FEISHU_TENANT_TOKEN_CACHE_SECONDS | tenant_access_token 本地缓存秒数 | +| FEISHU_EVENT_VERIFY_TOKEN | 事件订阅校验 Token,后续问答使用 | +| FEISHU_EVENT_ENCRYPT_KEY | 事件订阅加密 Key,后续问答使用 | + +敏感配置不得提交到代码库,只能通过本地 `.env`、部署环境变量或密钥管理系统注入。 + +首期配置维护方式: + +| 配置类型 | 维护方式 | 说明 | +| --- | --- | --- | +| 飞书 App ID | 环境变量 | 属于敏感信息,不进入数据库和代码库 | +| 飞书 App Secret | 环境变量 | 属于敏感信息,不进入数据库和代码库 | +| 指定接收人 open_id/user_id | 环境变量 | 首期固定发送到一个个人账号 | +| 通知开关 | 环境变量 | 便于本地、测试、部署环境切换 | +| 系统用户与飞书用户映射 | Django Admin | 便于非开发人员维护发起人和飞书用户标识 | + +### 7.5 系统用户与飞书用户映射 + +首期采用手工配置表维护系统用户与飞书用户之间的映射关系。系统在发送固定群通知时,根据批次 `user` 字段找到流程发起人或上传人,再从映射表中读取可用于飞书 @ 的用户标识。 + +建议字段: + +| 字段 | 说明 | +| --- | --- | +| system_username | 系统登录用户名 | +| system_user_id | 系统用户 ID,可选 | +| feishu_display_name | 飞书展示名称,便于管理员识别 | +| feishu_mobile | 飞书手机号,可选 | +| feishu_open_id | 飞书 open_id,可选 | +| feishu_user_id | 飞书 user_id,可选 | +| is_active | 是否启用该映射 | +| remark | 备注 | + +首期实现时,系统优先将通知发送给环境变量中配置的指定个人账号。用户映射表仍保留,用于后续从“固定个人账号”升级为“按流程发起人私聊”。若指定接收人未配置,系统不发送真实飞书消息,只记录配置缺失失败。 + +当同一个系统用户配置了多个飞书标识时,首期按以下优先级选择 @ 标识: + +```text +feishu_open_id -> feishu_user_id -> feishu_mobile +``` + +### 7.6 通知记录展示 + +首期需要在对应批次详情页展示通知状态,帮助用户和管理员判断飞书提醒是否已发送。 + +| 展示项 | 说明 | +| --- | --- | +| 通知通道 | mock、feishu_api 等 | +| 通知目标 | 指定个人账号名称或配置名称 | +| 接收人 | 首期指定接收人;后续可展示发起人/上传人的飞书展示名称 | +| 发送状态 | 成功、失败、待发送或未启用 | +| 发送时间 | 成功发送时间 | +| 失败原因 | 发送失败或配置异常时展示摘要 | + +--- + +## 八、飞书内问答需求预留 + +### 8.1 问答入口 + +| 入口 | 说明 | +| --- | --- | +| 私聊机器人 | 首期入口,用户直接向机器人询问自己的批次、文件和报告 | +| 群聊 @ 机器人 | 群内成员 @ 机器人询问某个批次或风险项 | +| 通知消息引用 | 用户收到通知后,基于批次编号继续提问 | + +### 8.2 问答能力边界 + +第一阶段飞书问答不应直接执行高风险写操作,只提供查询和解释: + +| 能力 | 是否纳入首期问答 | +| --- | --- | +| 查询批次状态 | 是 | +| 查询风险项摘要 | 是 | +| 查询缺失项摘要 | 是 | +| 查询生成文件摘要 | 是 | +| 解释整改建议 | 否,作为后续增强 | +| 重新发起工作流 | 否 | +| 删除文件或记录 | 否 | +| 自动关闭风险项 | 否 | +| 修改申报文件 | 否 | + +### 8.3 权限原则 + +飞书内问答必须解决用户身份和数据权限问题: + +| 场景 | 要求 | +| --- | --- | +| 私聊查询 | 普通用户只能查询自己发起或上传的批次;管理员可以查询全部批次 | +| 群内查询 | 只返回适合在群内公开的信息,敏感文件链接需谨慎 | +| 未绑定用户 | 提示先完成系统用户与飞书用户绑定 | +| 无权限数据 | 返回无权限提示,不泄露批次是否存在以外的敏感信息 | + +### 8.4 首期问答交互规则 + +首期私聊问答支持两类批次定位方式: + +| 方式 | 示例 | 说明 | +| --- | --- | --- | +| 明确批次号 | “查 RR-20260607-001” | 系统按批次编号精确查询 | +| 自然指代 | “查最近一个法规核查批次”“最新自动填表结果怎么样” | 系统在用户可访问范围内查找最近批次 | + +问答回复规则: + +| 规则 | 要求 | +| --- | --- | +| 链接返回 | 只有用户具备对应批次访问权限时才返回系统链接 | +| 无权限结果 | 提示无权限或无法访问,不返回敏感摘要和链接 | +| 回答粒度 | 返回批次状态、风险摘要、缺失摘要、导出摘要和下一步建议 | +| 日志留痕 | 记录用户问题、识别意图、查询对象、回答摘要、错误信息和处理时间,不保存完整回答正文 | + +--- + +## 九、异常与安全要求 + +| 场景 | 处理方式 | +| --- | --- | +| App ID/App Secret 或指定接收人未配置 | 自动回退 mock 或只记录未发送状态 | +| tenant_access_token 获取失败 | 记录失败,不发送消息,不阻断主流程 | +| 飞书接口超时 | 记录失败,不阻断主流程 | +| 飞书返回错误 | 记录错误码和错误信息,便于排查 | +| 消息过长 | 自动截断摘要,系统链接保留完整结果;首期不发送详细风险项或缺失项清单 | +| 重复触发 | 同一批次、同一工作流、同一状态只发送一次 | +| 敏感信息 | 通知正文避免包含完整文件内容、密钥、个人敏感信息 | +| 外部链接 | 首期使用本地地址;部署环境应升级为可信域名配置 | +| 回调伪造 | 后续事件订阅必须校验来源、签名、Token 或加密参数 | + +--- + +## 十、验收标准 + +### 10.1 短期通知验收 + +| 序号 | 验收项 | 标准 | +| --- | --- | --- | +| 1 | 配置关闭 | 未启用飞书通知时,工作流仍可正常完成并记录 mock 通知 | +| 2 | 配置开启 | 配置 App ID、App Secret 和指定个人 open_id/user_id 后,流程完成会向个人飞书账号发送提醒 | +| 3 | 成功记录 | 发送成功后通知记录状态为成功,并记录发送时间 | +| 4 | 失败记录 | token 获取失败、消息 API 错误、超时或配置错误时记录失败原因 | +| 5 | 不阻断主流程 | 通知失败不会导致工作流失败 | +| 6 | 内容完整 | 飞书消息包含工作流、批次、状态、摘要和系统入口 | +| 7 | 自动化测试 | 有单元测试覆盖通知构造、发送成功、发送失败、配置关闭 | +| 8 | token 管理 | 系统能获取并缓存 tenant_access_token,token 失效后可重新获取 | +| 9 | 后台映射 | 管理员可在 Django Admin 维护系统用户与飞书用户映射 | + +### 10.2 终极问答验收 + +| 序号 | 验收项 | 标准 | +| --- | --- | --- | +| 1 | 消息接收 | 系统能接收飞书私聊或群 @ 机器人消息 | +| 2 | 身份识别 | 能识别飞书用户并关联系统用户 | +| 3 | 意图识别 | 能区分批次查询、风险查询、文件查询、解释类问题 | +| 4 | 权限控制 | 普通用户只能查询自己发起或上传的批次;管理员可查询全部批次 | +| 5 | 消息回复 | 系统能通过飞书消息 API 回复用户 | +| 6 | 日志留痕 | 用户问题、意图、查询对象、回答摘要和错误信息可追溯,不保存完整回答正文 | +| 7 | 批次定位 | 支持明确批次号和“最近一个/最新批次”等自然说法 | +| 8 | 链接控制 | 只有用户有权限访问时才返回系统链接 | + +--- + +## 十一、阶段规划 + +### 阶段一:指定个人账号完成提醒 + +目标:使用飞书官方智能体/应用机器人消息 API 将流程完成提醒发送到指定个人账号。Demo 阶段先与当前系统负责人账号单独对接,暂不接入外部群聊。 + +建议内容: + +| 内容 | 说明 | +| --- | --- | +| 通知通道抽象 | 将 mock 和 feishu_api 封装为可切换通道 | +| 消息模板 | 输出流程完成摘要 | +| 指定接收人 | 根据环境变量配置的 open_id/user_id 发送给指定个人账号 | +| token 管理 | 使用 App ID/App Secret 获取并缓存 tenant_access_token | +| 消息 API | 使用指定个人 open_id/user_id 调用飞书发送消息 API | +| 通知记录 | 发送结果落库 | +| 配置开关 | 环境变量控制启用与否 | +| 测试覆盖 | 不依赖真实飞书也能测试发送逻辑 | +| 批次详情展示 | 在批次详情页展示通知状态和失败原因 | + +### 阶段一附加:飞书问答预留 + +目标:在不实现飞书事件回调和私聊问答的前提下,为后续问答 MVP 预留必要的数据结构、服务边界和权限规则。 + +建议内容: + +| 内容 | 说明 | +| --- | --- | +| 用户映射复用 | 飞书用户映射模型同时服务 @ 通知和后续私聊身份识别 | +| 查询服务边界 | 预留按批次号、最近批次、工作流类型查询结果摘要的服务接口 | +| 权限过滤规则 | 查询服务内置管理员全查、普通用户查自己批次的权限规则 | +| 问答日志模型预留 | 可先设计模型或接口,不要求首期接收飞书消息 | + +### 阶段二:按流程或项目分群 + +目标:支持不同流程、项目或业务线配置不同飞书目标。 + +建议内容: + +| 内容 | 说明 | +| --- | --- | +| 通知路由 | 根据 workflow_type、project、batch 等选择目标 | +| 通知策略 | 风险等级、完成状态、失败状态决定是否通知 | +| 消息降噪 | 避免同一批次重复刷屏 | + +### 阶段三:事件订阅与私聊问答 + +建议内容: + +| 内容 | 说明 | +| --- | --- | +| 事件回调 | 接收飞书私聊消息事件 | +| 用户绑定 | 使用飞书 open_id/user_id 映射系统用户 | +| 问答处理 | 查询批次状态、风险摘要、缺失摘要和导出摘要 | +| 回复消息 | 继续使用消息 API 回复用户 | + +### 阶段四:飞书内问答 + +目标:通过事件订阅接收用户消息,并调用系统 Agent 能力回答问题。 + +建议内容: + +| 内容 | 说明 | +| --- | --- | +| 事件回调 | 接收私聊和群 @ 消息 | +| 意图识别 | 解析查询对象和问题类型 | +| 数据查询 | 查询批次、风险、文件、报告和通知记录 | +| 回答生成 | 返回简洁、可追溯、带链接的回答 | +| 安全审计 | 记录问答日志和权限判断 | + +--- + +## 十二、待确认问题 + +| 编号 | 问题 | 推荐选项 | +| --- | --- | --- | +| Q1 | 短期通知发到哪里?固定飞书群、按业务群区分、还是按个人私聊? | 已调整:先发送到指定个人账号,暂不接入外部群聊 | +| Q2 | 首期接入哪些工作流?自动填表、法规核查、自动汇总是否都通知? | 已确认:三个流程都通知 | +| Q3 | 通知格式用普通文本、富文本还是飞书消息卡片? | 已确认:首期使用富文本 | +| Q4 | 系统链接使用本地地址还是部署域名? | Demo 本地,部署后改域名 | +| Q5 | 是否需要 @ 指定人员? | 已调整:首期为个人私聊通知,不需要群内 @ | +| Q6 | 是否需要失败重试? | 已确认:首期不自动重试,只记录失败 | +| Q7 | 飞书内问答优先支持私聊还是群 @? | 先私聊,后群 @ | + +--- + +## 十三、已确认决策 + +| 编号 | 决策 | 影响 | +| --- | --- | --- | +| D1 | 短期通知发送到指定个人账号,暂不接入外部群聊 | 首期需要配置个人 open_id/user_id;后续再扩展群聊、按发起人私聊和责任矩阵 | +| D2 | 首期接收人为配置中的固定个人账号 | 通知服务不再依赖批次 `user` 解析接收人;批次 `user` 仍用于摘要展示和后续按发起人私聊 | +| D3 | 首期采用手工配置表维护系统用户与飞书用户映射 | 避免首期被通讯录权限、用户自动绑定和开放平台审核阻塞;后续可升级为自动绑定 | +| D4 | 首期三个流程均发送飞书完成通知 | 自动汇总、法规核查、自动填表都需要接入统一通知服务;消息发送到指定个人账号 | +| D5 | 首期通知格式采用飞书富文本 | 消息构造需支持富文本结构、换行、重点字段和 @ 用户标签;暂不实现消息卡片按钮 | +| D6 | 成功、部分成功、失败三类状态均发送通知 | 消息模板需要按状态展示不同摘要和下一步动作 | +| D7 | 同一批次、同一工作流、同一状态只发送一次 | 通知记录需要保存可判重的业务键,发送前先检查历史成功或已发送记录 | +| D8 | 首期飞书发送失败不自动重试 | 通知失败只落库并暴露错误信息,不引入异步重试队列 | +| D9 | 飞书消息链接首期使用本地地址 | 满足本机 Demo;部署环境后续升级为可信域名配置 | +| D10 | 飞书消息采用摘要级内容粒度 | 私聊通知展示核心结果摘要和入口链接,不展开完整风险项、缺失项或文件明细 | +| D11 | 指定个人接收人未配置时不发送真实飞书消息 | 记录配置缺失失败或回退 mock;用户映射缺失不影响首期固定个人通知 | +| D12 | 通知记录只保存发送摘要,不保存完整富文本 payload | 降低记录冗余和敏感信息留存风险;排查时依赖摘要、状态、错误信息和业务上下文 | +| D13 | App ID、App Secret、指定个人 open_id/user_id 等敏感配置通过环境变量维护,用户映射通过 Django Admin 维护 | 兼顾安全性和运维便利性;用户映射服务于后续按发起人私聊和问答身份识别 | +| D14 | 首期使用 tenant_access_token + 飞书消息 API 发送通知 | 通知客户端需要实现 token 获取、缓存、失效重取和消息 API 错误处理 | +| D15 | 飞书内问答首期入口为私聊机器人 | 优先解决个人查询场景,降低群聊权限泄露风险 | +| D16 | 飞书内问答首期回答批次状态、风险摘要、缺失摘要和导出摘要 | 不在首期做具体风险解释和复杂整改建议生成 | +| D17 | 私聊问答支持明确批次号和“最近/最新”自然说法 | 问答解析需要支持批次编号识别和按工作流类型查询最近可访问批次 | +| D18 | 问答权限为管理员可查全部,普通用户只能查自己发起或上传的批次 | 需要识别系统管理员身份,并在查询层统一做权限过滤 | +| D19 | 问答回复仅在用户有权限时返回系统链接 | 链接生成必须在权限校验之后执行 | +| D20 | 问答日志记录问题、意图、查询对象和回答摘要,不保存完整回答 | 兼顾审计排查与敏感信息最小留存 | +| D21 | 首期实现指定个人私聊通知,并预留飞书问答数据模型和服务边界 | 不在首期实现飞书事件回调和交互式问答,降低一次性交付风险 | +| D22 | 批次详情页需要展示通知状态 | 用户无需进入数据库或 Admin 即可确认飞书提醒是否发送成功 | +| D23 | 多个飞书标识的 @ 优先级为 `open_id > user_id > mobile` | 优先使用稳定标识,手机号作为兜底 | +| D24 | 本需求文档版本升级为 V1.0 | 当前决策已足够进入功能设计阶段 | diff --git a/docs/1.需求分析/5.第1章监管信息材料包生成.md b/docs/1.需求分析/5.第1章监管信息材料包生成.md new file mode 100644 index 0000000..0759e35 --- /dev/null +++ b/docs/1.需求分析/5.第1章监管信息材料包生成.md @@ -0,0 +1,450 @@ +# 第1章监管信息材料包生成需求分析 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 原始输入 | docs/0.原始材料/目标产品说明书.docx | +| 样例模板 | docs/0.原始材料/第1章 监管信息 | +| 法规材料 | docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告 | +| 功能主题 | 从产品说明书生成第1章监管信息材料包 | +| 工作流名称 | 第1章监管信息材料包生成 | +| 工作流编码 | regulatory_info_package | +| 批次号规则 | RIP-YYYYMMDDHHMMSS-abcdef | +| 分析日期 | 2026-06-10 | +| 分析版本 | V1.0 | + +--- + +## 一、需求背景 + +体外诊断试剂注册申报资料中,第1章监管信息包含监管信息目录、申请表、产品列表、申报前沟通说明、符合标准清单、真实性声明和符合性声明等材料。注册人员通常需要根据产品说明书、企业信息和法规要求手工整理这些文件,容易出现产品名称、包装规格、组成成分、预期用途等字段重复录入、漏填、格式不一致和待补信息不醒目的问题。 + +本需求新增独立工作流:用户上传或选择一个产品说明书后,系统以既有 `第1章 监管信息` 样例文件作为模板,抽取说明书中的产品关键信息,生成一套类似样例目录的第1章监管信息材料包。生成结果以 zip 压缩包作为主下载入口,同时保留单文件辅助下载。 + +该工作流可以复用现有自动填表工作流中已拆分出的字段抽取、LLM 调用、Word 写入、导出下载、批次事件和通知能力,但不并入 `application_form_fill`,而是作为独立工作流建设。 + +--- + +## 二、需求范围 + +### 2.1 本期范围 + +| 序号 | 范围项 | 说明 | +| --- | --- | --- | +| 1 | 独立工作流 | 新增 `regulatory_info_package`,不复用 `application_form_fill` 的 workflow_type | +| 2 | 单说明书输入 | 本期只支持一个产品说明书作为主输入 | +| 3 | 模板复用 | 以 `docs/0.原始材料/第1章 监管信息` 下的样例文件作为生成模板 | +| 4 | 固定输出文件 | 固定生成 7 个第1章监管信息文件 | +| 5 | 代码抽取与 LLM 抽取并行 | 规则/代码抽取与 LLM 结构化抽取并行处理,合并后写入模板 | +| 6 | 尽量多填 | 对说明书中可识别的产品名称、包装规格、预期用途、组成成分、储存条件、适用仪器、样本类型、检测靶标等字段尽量填入 | +| 7 | 缺失项标记 | 系统新填入的缺失项使用 `/`,并设置黄色底色提醒负责人补充 | +| 8 | LLM-only 标记 | 代码抽取未取到但 LLM 抽取到的字段,也需要在输出文件中高亮提示人工复核 | +| 9 | 模板字段化 | 优先将样例模板整理为 Agent/代码可识别字段模板,使用内容控件 Tag 或稳定占位符,代码只填内容不手改格式 | +| 10 | doc 能力增强 | `.doc` 文档按能力驱动处理:有原生能力时优先原生写入,无原生能力时明确记录并允许 `.docx` 兜底,不静默输出未改写文件 | +| 11 | zip 主输出 | 生成 `第1章 监管信息(预生成版).zip` 作为主下载入口,单文件作为辅助下载 | +| 12 | 对话唤起提示 | 在对话框底部增加本工作流的唤起提示词 | +| 13 | LLM 意图判断 | 触发判断不能只依赖固定关键词,需要引入 LLM 判断用户是否要生成第1章监管信息材料包 | + +### 2.2 非本期范围 + +| 序号 | 范围项 | 说明 | +| --- | --- | --- | +| 1 | 多资料综合生成 | 本期不从产品技术要求、检验报告、企业证照等多文件综合生成 | +| 2 | 人工在线编辑 | 本期只生成文件并标记待确认项,不提供网页内字段编辑 | +| 3 | 自动保证法规最终准确 | 标准清单、分类编码、管理类别等无法从说明书确认的信息仍需负责人确认 | +| 4 | 自动提交监管系统 | 本期只生成申报材料包,不对接外部申报平台 | +| 5 | 版式人工校订替代 | 系统尽量保持模板版式,但最终提交前仍需人工核对 | + +--- + +## 三、输入与触发 + +### 3.1 输入文件规则 + +| 场景 | 处理规则 | +| --- | --- | +| 用户上传一个 `.docx` 说明书 | 直接作为本次输入 | +| 用户上传多个文件 | 优先选择文件名包含“说明书”的 `.docx` | +| 多个说明书候选 | 工作流进入待确认状态,提示用户选择 | +| 未找到说明书 | 提示用户上传产品说明书 | +| 非 `.docx` 说明书 | 本期可提示格式不支持,后续扩展 `.doc`、PDF 或 OCR | + +### 3.2 对话触发规则 + +固定提示词需要支持: + +| 触发表达 | 触发结果 | +| --- | --- | +| 根据说明书生成第1章监管信息 | 启动第1章监管信息材料包生成 | +| 生成监管信息材料包 | 启动第1章监管信息材料包生成 | +| 从说明书生成第1章材料 | 启动第1章监管信息材料包生成 | + +除固定表达外,系统需要引入 LLM 意图判断。当用户自然语言表达包含“根据说明书”“第1章”“监管信息”“材料包”“申请表/产品列表/声明”等意图组合时,LLM 可判断为 `regulatory_info_package`。规则命中优先,规则未命中时再进入 LLM 路由,避免只靠固定模板。 + +### 3.3 对话框底部唤起提示 + +对话框底部快捷提示词新增: + +```text +根据说明书生成第1章监管信息 +``` + +后续可追加: + +```text +生成监管信息材料包 +从说明书生成第1章材料 +``` + +--- + +## 四、输出文件范围 + +本期固定生成与样例目录一致的 7 个文件: + +| 序号 | 输出文件 | 模板来源 | 生成规则 | +| --- | --- | --- | --- | +| 1 | CH1.2 监管信息目录.docx | 样例 `CH1.2 监管信息目录.docx` | 替换产品名称,目录结构和页码沿用样例 | +| 2 | CH1.4 申请表.docx | 样例 `CH1.4 申请表.docx` | 尽量填入说明书字段,未知项填 `/` 并黄底 | +| 3 | CH1.5 产品列表.docx | 样例 `CH1.5 产品列表.docx` | 按样例表头重建产品列表,货号留空并黄底 | +| 4 | CH1.9 产品申报前沟通的说明.doc | 样例 `CH1.9 产品申报前沟通的说明.doc` | `.doc` 应支持与 `.docx` 等价替换能力 | +| 5 | CH1.11.1 符合标准的清单.docx | 样例 `CH1.11.1 符合标准的清单.docx` | 从说明书和 RAG/法规知识库提取或推荐标准,非明确项需高亮待确认 | +| 6 | CH1.11.5 真实性声明.docx | 样例 `CH1.11.5 真实性声明.docx` | 保留样例正文结构,替换产品名称,公司名位置黄底 `/` | +| 7 | CH1.11.6 符合性声明.docx | 样例 `CH1.11.6 符合性声明.docx` | 保留样例正文结构,替换产品名称,公司名位置黄底 `/` | + +### 4.1 下载形态 + +| 输出类型 | 要求 | +| --- | --- | +| zip 主入口 | 生成 `第1章 监管信息(预生成版).zip`,只包含成功或兜底成功的文件 | +| 单文件下载 | 每个生成文件均可作为辅助下载项展示 | +| 追溯清单 | 建议生成 JSON/Excel,记录字段来源、抽取方式、高亮原因和待确认项 | + +--- + +## 五、字段抽取与填写规则 + +### 5.1 抽取字段范围 + +系统应从说明书中尽量抽取以下字段: + +| 字段 | 示例来源 | +| --- | --- | +| 产品名称 | `【产品名称】` | +| 包装规格 | `【包装规格】` | +| 预期用途 | `【预期用途】` | +| 检测原理/方法原理 | `【检测原理】` | +| 主要组成成分 | `【主要组成成分】` 及其下方表格 | +| 储存条件及有效期 | `【储存条件及有效期】` | +| 样本类型 | `【样本要求】` 中的适用样本类型 | +| 检测靶标 | 预期用途或检测原理中的基因、病原体、抗原、抗体等 | +| 适用仪器 | `【适用仪器】` | +| 检验方法 | `【检验方法】` | +| 生产日期和使用期限描述 | 储存条件章节 | + +字段抽取采用规则/代码抽取与 LLM 结构化抽取并行模式: + +```text +读取说明书 +-> 规则/代码抽取 +-> LLM 结构化抽取 +-> 字段合并 +-> 标记字段来源和置信度 +-> 写入模板 +``` + +### 5.2 合并与高亮规则 + +| 场景 | 处理规则 | +| --- | --- | +| 代码抽取和 LLM 都命中且结果一致 | 正常写入,不强制高亮 | +| 代码抽取和 LLM 都命中但结果不一致 | 优先按规则配置选择,写入值高亮并进入追溯清单 | +| 代码抽取未命中,LLM 命中 | 写入 LLM 值,并高亮提示人工复核 | +| 代码抽取命中,LLM 未命中 | 正常写入,追溯记录代码抽取来源 | +| 两者均未命中 | 写入 `/` 并设置黄色底色 | +| 企业信息缺失 | 写入 `/` 并设置黄色底色 | + +高亮含义: + +| 高亮类型 | 视觉要求 | 含义 | +| --- | --- | --- | +| 缺失项高亮 | 黄色底色 | 说明书无法提供,负责人需填写 | +| LLM-only 高亮 | 黄色底色,可在追溯清单标记 `llm_only` | 代码未抽到,仅 LLM 推断,需要复核 | +| 冲突高亮 | 黄色底色,可配合红色字体 | 规则结果与 LLM 结果不一致 | + +仅标记系统新填入的缺失项或需复核项。样例模板中原本存在的 `/` 不统一高亮,避免整份文件过度标记。 + +--- + +## 六、各文件生成规则 + +### 6.1 CH1.2 监管信息目录 + +| 项目 | 规则 | +| --- | --- | +| 产品名称 | 替换为说明书抽取的产品名称 | +| 目录条目 | 沿用样例目录结构 | +| 适用情况 | 沿用样例 | +| 资料名称 | 沿用样例 | +| 页码 | 沿用样例页码 | + +### 6.2 CH1.4 申请表 + +| 字段类型 | 规则 | +| --- | --- | +| 产品名称 | 从说明书抽取 | +| 包装规格 | 从说明书抽取 | +| 主要组成成分 | 优先使用说明书组成成分摘要或附件提示 | +| 预期用途 | 从说明书抽取 | +| 产品储存条件及有效期 | 从说明书抽取 | +| 方法原理 | 从说明书检测原理抽取 | +| 产品类别 | 缺失,填 `/` 并黄底 | +| 分类编码 | 缺失,填 `/` 并黄底 | +| 临床评价路径 | 缺失,填 `/` 并黄底 | +| 申请人信息 | 缺失,填 `/` 并黄底 | +| 联系人、法定代表人、邮箱、组织机构代码 | 缺失,填 `/` 并黄底 | +| 生产地址 | 缺失,填 `/` 并黄底 | + +管理类别、分类编码、临床评价路径、UDI、国家标准品/强制标准等不得根据经验自动下结论,全部按待确认处理。 + +### 6.3 CH1.5 产品列表 + +产品列表需要转成样例表头: + +| 包装规格 | 货号 | 组成 | 组分 | 主要组成成分 | 规格/数量 | +| --- | --- | --- | --- | --- | --- | + +生成规则: + +| 字段 | 规则 | +| --- | --- | +| 包装规格 | 从说明书组成成分表的规格列或包装规格章节抽取 | +| 货号 | 说明书未提供,填 `/` 并黄底 | +| 组成 | 根据组分名称推断为反应液、质控品、处理液、增强剂等;无法判断则填 `/` 并黄底 | +| 组分 | 使用说明书表格中的组分名称 | +| 主要组成成分 | 使用说明书表格中的主要组成成分 | +| 规格/数量 | 使用说明书表格中的对应规格数量 | + +目标产品说明书中存在规格A大包装、规格A分管包装、规格B大管包装等多个组成表,系统应尽量展开为多行产品列表。 + +### 6.4 CH1.9 产品申报前沟通的说明 + +`CH1.9` 当前为 `.doc` 格式。本工作流要求 `.doc` 文档具备与 `.docx` 等价的原始功能,即模板复制、文本定位、字段替换、高亮标记、导出和打包均应支持 `.doc`。 + +实现上不应只把转换作为唯一方案。可选技术路径包括: + +| 路径 | 说明 | +| --- | --- | +| 原生 `.doc` 处理 | 优先探索可直接读取和写入 `.doc` 的库、COM 或二进制文档处理能力 | +| Office/COM 自动化 | Windows 环境下通过 Word COM 直接打开 `.doc` 并原格式写入保存 | +| LibreOffice UNO/API | 通过 LibreOffice API 直接处理旧版 Word,而不只作为离线预转换 | +| 转换兜底 | 当原生处理不可用时,可作为兜底手段,但不能作为需求定义中的唯一能力 | + +如运行环境不具备 `.doc` 写入能力,工作流应明确失败原因或降级提示,不应静默输出未改写文件。 + +### 6.5 CH1.11.1 符合标准的清单 + +生成规则: + +| 来源 | 处理方式 | +| --- | --- | +| 说明书明确出现的标准号 | 可直接写入,并记录来源片段 | +| RAG/法规知识库命中的候选标准 | 可作为候选写入或追溯提示,但需高亮待确认 | +| 样例中的标准清单 | 不可无条件沿用 | +| 无法确认的标准 | 填 `/` 并黄底 | + +法规材料目录中存在 `医疗器械注册申报资料和批准证明文件格式要求(体外诊断试剂).doc`、`体外诊断试剂注册申报资料要求及说明.doc`、`体外诊断试剂安全和性能基本原则清单.doc` 等材料。其中安全和性能基本原则清单属于第3章非临床资料,不直接等同于 `CH1.11.1 符合标准的清单`。系统应优先查询已上传 RAG/法规知识库来确认标准清单要求;未命中时不得强行套用样例标准。 + +### 6.6 CH1.11.5 真实性声明 + +| 项目 | 规则 | +| --- | --- | +| 正文结构 | 保留样例结构 | +| 产品名称 | 替换为说明书抽取的产品名称 | +| 公司名/申请人 | 填 `/` 并黄底 | +| 日期 | 使用当天日期 | +| 材料列表 | 沿用样例材料列表 | + +### 6.7 CH1.11.6 符合性声明 + +| 项目 | 规则 | +| --- | --- | +| 正文结构 | 保留样例结构 | +| 产品名称 | 替换为说明书抽取的产品名称 | +| 公司名/申请人 | 填 `/` 并黄底 | +| 日期 | 使用当天日期 | + +--- + +## 七、工作流设计 + +### 7.1 主流程 + +```text +用户上传或选择产品说明书 +-> 用户触发“根据说明书生成第1章监管信息” +-> 系统通过规则和 LLM 判断工作流意图 +-> 创建 regulatory_info_package 批次 +-> 校验输入说明书 +-> 复制第1章监管信息样例模板到批次目录 +-> 抽取说明书文本、段落和表格 +-> 规则/代码抽取字段 +-> LLM 结构化抽取字段 +-> 合并字段并识别缺失、LLM-only 和冲突项 +-> 生成 7 个目标文件 +-> 对缺失项、LLM-only 项和冲突项进行高亮 +-> 生成追溯清单 +-> 打包第1章监管信息 zip +-> 写入导出记录 +-> 对话框展示 zip 主下载入口、单文件下载和待确认摘要 +``` + +### 7.2 节点建议 + +| 节点编码 | 节点名称 | 成功条件 | +| --- | --- | --- | +| prepare | 准备资料 | 找到唯一说明书输入 | +| template_copy | 复制模板 | 7 个样例模板复制到批次目录 | +| text_extract | 抽取说明书 | 提取说明书段落和表格 | +| field_extract | 抽取字段 | 规则和 LLM 抽取结果均留底 | +| field_merge | 合并字段 | 输出最终字段、缺失项、LLM-only 项和冲突项 | +| generate_docs | 生成材料 | 7 个文件生成完成 | +| highlight_review_items | 标记待确认 | 缺失项、LLM-only、冲突项完成高亮 | +| trace_export | 追溯清单 | 生成 JSON/Excel 追溯清单 | +| zip_export | 打包下载 | 生成 `第1章 监管信息(预生成版).zip` | +| completed | 完成 | 更新批次状态并返回下载摘要 | + +### 7.3 状态建议 + +| 状态 | 含义 | +| --- | --- | +| pending | 已创建,等待执行 | +| running | 执行中 | +| waiting_user | 多个说明书或缺少说明书,等待用户确认 | +| success | zip 和必要单文件生成成功 | +| partial_success | zip 已生成,但部分 `.doc`、追溯清单或高亮处理失败 | +| failed | 关键文件均未生成 | + +--- + +## 八、数据与产物 + +### 8.1 批次数据 + +建议新增独立批次模型或等价数据结构,记录: + +| 字段 | 说明 | +| --- | --- | +| batch_no | RIP 批次号 | +| workflow_type | regulatory_info_package | +| conversation | 所属对话 | +| user | 发起用户 | +| trigger_message | 触发消息 | +| source_instruction_file | 输入说明书 | +| product_name | 抽取到的产品名称 | +| status | 批次状态 | +| work_dir | 批次工作目录 | +| missing_fields | 缺失字段清单 | +| llm_only_fields | 仅 LLM 命中的字段 | +| conflict_fields | 冲突字段 | +| risk_notes | `.doc` 处理、标准清单待确认等风险提示 | + +### 8.2 追溯清单 + +追溯清单至少记录: + +| 字段 | 说明 | +| --- | --- | +| target_file | 目标文件 | +| target_field | 目标字段 | +| final_value | 写入值 | +| extraction_source | rule、llm、missing、rag_candidate | +| evidence | 来源片段 | +| highlight_reason | missing、llm_only、conflict、rag_candidate | +| needs_review | 是否需要负责人确认 | + +--- + +## 九、界面与交互 + +### 9.1 对话回复 + +工作流完成后,对话框展示: + +| 信息 | 说明 | +| --- | --- | +| 批次号 | RIP 批次号 | +| 产品名称 | 抽取到的产品名称 | +| 主下载 | `第1章 监管信息(预生成版).zip` | +| 单文件下载 | 7 个文件列表 | +| 待确认摘要 | 缺失字段数、LLM-only 字段数、冲突字段数 | +| `.doc` 状态 | CH1.9 是否成功完成 `.doc` 写入 | +| 标准清单提示 | 标准来源和待确认说明 | + +### 9.2 工作流卡片 + +前端需新增 `regulatory_info_package` 工作流卡片,展示节点状态和导出结果。对话框底部新增快捷唤起提示词: + +```text +根据说明书生成第1章监管信息 +``` + +--- + +## 十、异常与降级 + +| 异常场景 | 处理方式 | +| --- | --- | +| 未上传说明书 | 提示用户上传产品说明书 | +| 多个说明书候选 | 进入 waiting_user,提示选择 | +| 产品名称未抽到 | 目标文件产品名位置填 `/` 并黄底 | +| 企业信息缺失 | 相关位置填 `/` 并黄底 | +| LLM 调用失败 | 使用规则抽取结果继续生成,并记录风险提示 | +| 规则抽取失败 | 使用 LLM 结果继续生成,LLM-only 字段高亮 | +| RAG/法规知识库不可用 | 标准清单不自动套用样例,写入 `/` 并黄底 | +| `.doc` 原生处理失败 | 批次标记 partial_success 或 failed,明确提示 CH1.9 处理失败原因 | +| zip 打包失败 | 保留单文件下载,并提示压缩包生成失败 | + +--- + +## 十一、验收标准 + +| 序号 | 验收项 | 标准 | +| --- | --- | --- | +| 1 | 触发识别 | 用户输入“根据说明书生成第1章监管信息”可启动 `regulatory_info_package` | +| 2 | LLM 路由 | 非固定话术但语义明确时,可由 LLM 判断进入本工作流 | +| 3 | 输入选择 | 单说明书可直接执行,多说明书进入待确认 | +| 4 | 输出文件 | 生成 7 个与样例同名或同语义的第1章文件 | +| 5 | zip 下载 | 生成 `第1章 监管信息(预生成版).zip` 作为主下载入口 | +| 6 | 单文件下载 | 7 个生成文件均可单独下载 | +| 7 | 产品名称替换 | 目录、申请表、声明类文件中的产品名称替换为说明书产品名称 | +| 8 | 产品列表 | CH1.5 使用样例表头展开说明书组成成分,货号填 `/` 并黄底 | +| 9 | 缺失项高亮 | 系统新填入的 `/` 均有黄色底色 | +| 10 | LLM-only 高亮 | 代码未抽到但 LLM 抽到的字段在文件中高亮 | +| 11 | 标准清单 | 不无条件沿用样例标准;无法确认时填 `/` 并黄底 | +| 12 | 日期 | 声明类文件日期使用当天日期 | +| 13 | `.doc` 支持 | CH1.9 `.doc` 具备与 `.docx` 等价的处理能力,失败时明确提示 | +| 14 | 追溯清单 | 输出字段来源、抽取方式和高亮原因 | +| 15 | 权限隔离 | 用户只能访问自己对话下的批次和导出文件 | + +--- + +## 十二、已确认结论 + +| 编号 | 结论 | +| --- | --- | +| D1 | 输出范围固定为样例第1章监管信息目录下的 7 个文件 | +| D2 | 样例文件作为模板使用,不只是效果参考 | +| D3 | 企业信息、申请人信息缺失时不沿用样例公司,填 `/` 并黄底 | +| D4 | 管理类别、分类编码、临床评价路径等无法从说明书确认的信息填 `/` 并黄底 | +| D5 | 产品列表货号留空,填 `/` 并黄底 | +| D6 | 标准清单不得无条件沿用样例,优先从说明书和 RAG/法规知识库确认 | +| D7 | 声明日期使用当天日期 | +| D8 | 新建独立工作流,可复用原自动填表工作流拆出的 skill/service | +| D9 | 需求分析文档新增为 `docs/1.需求分析/5.第1章监管信息材料包生成.md` | +| D10 | zip 作为主入口,单文件作为辅助下载 | +| D11 | 对话框底部增加工作流唤起提示词 | +| D12 | 模板优先字段化,使用内容控件 Tag 或稳定占位符服务 Agent/代码填充,行标签定位仅作为兜底 | +| D13 | `.doc` 要按能力驱动实现与 `.docx` 等价能力;原生能力不可用时允许 `.docx` 兜底并明确提示 | +| D14 | 触发判断需要引入 LLM,不只依赖固定关键词 | diff --git a/docs/2.功能设计/1.自动汇总.md b/docs/2.功能设计/1.自动汇总.md new file mode 100644 index 0000000..4e3fcb4 --- /dev/null +++ b/docs/2.功能设计/1.自动汇总.md @@ -0,0 +1,597 @@ +# 自动汇总文件夹文件目录与页数流程功能设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/1.自动汇总.md | +| 功能名称 | 自动汇总文件夹文件目录与页数 | +| 所属模块 | 审核智能体 review_agent | +| 设计日期 | 2026-06-05 | +| 设计版本 | V1.0 | + +--- + +## 一、设计目标 + +本功能面向试剂盒 NMPA 注册申报资料审核场景,支持用户在 AI 对话页上传压缩包或多个文件,由后台异步执行文件汇总工作流,自动完成解压、文件清单扫描、页数统计、产品名识别、Markdown 报告生成和 Excel 导出。 + +前台 AI 对话页需要展示一个工作流卡片,实时呈现“上传中、解压中、扫描中、解析中、识别中、输出中、完成/失败”等节点状态;工作流完成后,AI 对话框展示 Markdown 简表,并提供 Markdown 报告和 Excel 明细下载链接。上传文件、批次、节点状态、文件明细和导出结果均需要与当前对话绑定,一个对话对应一套文件,不能跨对话串用。 + +--- + +## 二、总体架构 + +### 2.1 架构原则 + +| 原则 | 说明 | +| --- | --- | +| 对话绑定 | 上传文件、处理批次、结果文件均绑定当前 Conversation | +| 按需加载 | 将文件处理流程拆分为多个可单独执行的 Skill,按工作流节点调用 | +| 后台异步 | 用户提交后后台执行工作流,前台通过 SSE 接收状态事件 | +| 失败隔离 | 解压失败导致批次失败;单文件解析失败最多重试 3 次后记录异常并继续 | +| 可迁移 MCP | Demo 阶段使用项目内 Skill 注册与调用,后续可迁移为 MCP Tool | +| 可追溯 | 每个节点状态、文件统计结果、导出文件均持久化入库 | + +### 2.2 逻辑架构 + +```mermaid +flowchart TD + A["AI 对话页"] --> B["上传接收接口"] + B --> C["工作流任务表"] + C --> D["后台工作流执行器"] + D --> E["Skill 注册表"] + E --> F1["上传接收 Skill"] + E --> F2["压缩包解压 Skill"] + E --> F3["文件清单扫描 Skill"] + E --> F4["文档页数统计 Skill"] + E --> F5["产品信息识别 Skill"] + E --> F6["汇总报告生成 Skill"] + E --> F7["Excel 导出 Skill"] + D --> G["数据库存档"] + D --> H["导出文件存储"] + D --> I["SSE 状态事件"] + I --> A +``` + +### 2.3 技术选型 + +| 设计项 | Demo 方案 | 后续演进 | +| --- | --- | --- | +| 工作流编排 | 项目内 LangGraph 风格节点图执行器 | 接入 LangGraph | +| Skill 形态 | Python 类或函数注册表,按节点名称动态调用 | 封装为 MCP Tool | +| 后台任务 | Django 后台线程 + 数据库状态 | Celery/RQ + Redis | +| 实时更新 | 沿用现有 SSE 流式能力,新增 workflow 事件 | 独立任务事件通道 | +| 文件存储 | 本地 media 目录 | 对象存储或加密文件服务 | +| Markdown 渲染 | 前端引入安全 Markdown 渲染 | 统一富文本渲染组件 | + +--- + +## 三、工作流设计 + +### 3.1 节点图 + +```mermaid +flowchart LR + N1["上传中"] --> N2{"是否压缩包"} + N2 -->|"是"| N3["解压中"] + N2 -->|"否"| N4["扫描中"] + N3 --> N4 + N4 --> N5["解析页数中"] + N5 --> N6["识别产品名中"] + N6 --> N7["输出中"] + N7 --> N8["完成"] + N3 -->|"解压失败"| F["失败"] + N7 -->|"导出失败"| F +``` + +### 3.2 节点定义 + +| 节点编码 | 节点名称 | 触发 Skill | 成功条件 | 失败处理 | +| --- | --- | --- | --- | --- | +| upload | 上传中 | 上传接收 Skill | 原始文件保存成功,批次创建成功 | 批次失败 | +| extract | 解压中 | 压缩包解压 Skill | zip/rar/7z 等压缩包解压成功 | 批次失败 | +| inventory | 扫描中 | 文件清单扫描 Skill | 生成文件清单 | 批次失败或空文件提示 | +| page_count | 解析页数中 | 文档页数统计 Skill | 支持类型完成页数统计或异常记录 | 单文件失败不阻断 | +| product_detect | 识别产品名中 | 产品信息识别 Skill | 识别到产品名或返回空 | 不阻断 | +| report | 输出中 | 汇总报告生成 Skill | Markdown 报告与对话简表生成成功 | 批次失败 | +| export | 输出中 | Excel 导出 Skill | Excel 明细生成成功 | 批次失败或记录导出异常 | +| completed | 完成 | 工作流执行器 | 所有必需产物完成 | 写入完成状态 | + +### 3.3 状态机 + +| 状态 | 含义 | +| --- | --- | +| pending | 已创建,等待执行 | +| running | 执行中 | +| retrying | 单文件解析失败,正在重试 | +| success | 节点执行成功 | +| failed | 节点或批次失败 | +| skipped | 当前节点不需要执行,例如非压缩包跳过解压 | + +--- + +## 四、Skill 设计 + +### 4.1 Skill 注册与调用 + +Demo 阶段在项目内定义 Skill 注册表,工作流执行器根据节点编码按需加载并执行对应 Skill。 + +```text +WorkflowExecutor +-> 根据当前节点读取 Skill 名称 +-> 从 SkillRegistry 获取 Skill +-> 执行 skill.run(context) +-> 写入节点状态与结果 +-> 发送 SSE 状态事件 +-> 进入下一节点 +``` + +后续 MCP 化时,每个 Skill 可映射为独立 MCP Tool,输入输出保持稳定 JSON 契约。 + +### 4.2 上传接收 Skill + +| 项目 | 说明 | +| --- | --- | +| 中文名称 | 上传接收 Skill | +| 职责 | 接收对话页上传的压缩包或多个文件,保存原始文件,创建上传批次 | +| 输入 | conversation_id、user_id、uploaded_files | +| 输出 | batch_id、upload_file_ids、upload_type、original_storage_paths | +| 数据写入 | FileSummaryBatch、FileAttachment、FileSummaryBatchAttachment | +| 关键规则 | 文件必须绑定当前 Conversation;同一对话只使用本对话上传的文件 | + +### 4.3 压缩包解压 Skill + +| 项目 | 说明 | +| --- | --- | +| 中文名称 | 压缩包解压 Skill | +| 职责 | 识别并解压 zip、rar、7z 等常见压缩包,保留目录结构 | +| 输入 | batch_id、source_file_path | +| 输出 | extract_root、extracted_file_count | +| 数据写入 | WorkflowNodeRun、批次工作目录 | +| 关键规则 | 防止路径穿越;解压目录必须限定在批次工作目录内;解压失败批次失败 | + +### 4.4 文件清单扫描 Skill + +| 项目 | 说明 | +| --- | --- | +| 中文名称 | 文件清单扫描 Skill | +| 职责 | 遍历解压目录或散装文件目录,生成文件清单 | +| 输入 | batch_id、scan_root | +| 输出 | inventory_items | +| 数据写入 | FileSummaryItem | +| 关键规则 | 保留目录层级;散装文件归入同一批次根目录;隐藏文件和空文件可标记跳过 | + +### 4.5 文档页数统计 Skill + +| 项目 | 说明 | +| --- | --- | +| 中文名称 | 文档页数统计 Skill | +| 职责 | 对支持类型统计页数或数量 | +| 输入 | batch_id、FileSummaryItem 列表 | +| 输出 | page_count、statistics_status、error_message | +| 数据写入 | FileSummaryItem | +| 关键规则 | 支持 pdf、doc、docx、xls、xlsx、ppt、pptx;单文件失败最多重试 3 次,仍失败则记录异常并继续 | + +页数统计口径: + +| 文件类型 | 统计口径 | +| --- | --- | +| pdf | PDF 页面数量 | +| doc/docx | 优先转 PDF 后统计页面数量 | +| xls/xlsx | Demo 阶段按工作表数量统计 | +| ppt/pptx | 按幻灯片数量统计 | + +### 4.6 产品信息识别 Skill + +| 项目 | 说明 | +| --- | --- | +| 中文名称 | 产品信息识别 Skill | +| 职责 | 尝试识别产品名称,并用于更新对话标题 | +| 输入 | batch_id、文件名、目录名、可读取文本片段 | +| 输出 | product_name、confidence、evidence | +| 数据写入 | FileSummaryBatch.product_name、Conversation.title | +| 关键规则 | 优先从文件名和目录名识别;其次读取文档首页或关键文本;识别失败不阻断流程 | + +会话标题规则: + +| 场景 | 标题处理 | +| --- | --- | +| 识别到产品名 | 更新为“产品名-文件汇总” | +| 未识别产品名 | 保持原对话标题 | +| 用户已手动命名 | 可保留用户标题,产品名写入批次信息 | + +### 4.7 汇总报告生成 Skill + +| 项目 | 说明 | +| --- | --- | +| 中文名称 | 汇总报告生成 Skill | +| 职责 | 生成完整 Markdown 报告和对话框展示简表 | +| 输入 | batch_id、统计摘要、文件明细、异常清单 | +| 输出 | markdown_report_path、assistant_markdown_summary | +| 数据写入 | ExportedSummaryFile、Message | +| 关键规则 | Markdown 简表需要适合前端对话框渲染;完整报告包含全部文件明细 | + +### 4.8 Excel 导出 Skill + +| 项目 | 说明 | +| --- | --- | +| 中文名称 | Excel 导出 Skill | +| 职责 | 生成 Excel 汇总文件 | +| 输入 | batch_id、统计摘要、文件明细 | +| 输出 | excel_path、download_url | +| 数据写入 | ExportedSummaryFile | +| 关键规则 | 至少包含“汇总信息”“文件明细”两个 Sheet | + +--- + +## 五、数据模型设计 + +### 5.1 FileSummaryBatch + +文件汇总批次,表示一次对话内的文件汇总任务。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| id | BigAutoField | 主键 | +| conversation | ForeignKey(Conversation) | 绑定对话 | +| user | ForeignKey(User) | 上传用户 | +| batch_no | CharField | 批次编号 | +| product_name | CharField | 识别出的产品名,可为空 | +| upload_type | CharField | archive、multi_file | +| status | CharField | pending、running、success、failed | +| total_files | Integer | 文件总数 | +| supported_files | Integer | 支持统计的文件数 | +| success_files | Integer | 统计成功数 | +| failed_files | Integer | 统计失败数 | +| unsupported_files | Integer | 不支持文件数 | +| total_pages | Integer | 总页数 | +| work_dir | CharField | 批次工作目录 | +| error_message | TextField | 批次异常说明 | +| created_at | DateTimeField | 创建时间 | +| started_at | DateTimeField | 开始时间 | +| finished_at | DateTimeField | 完成时间 | + +### 5.2 FileAttachment + +上传原始文件记录。用户上传即存储为 `FileAttachment`,批次启动时再通过 `FileSummaryBatchAttachment` 固化本次使用的附件版本。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| id | BigAutoField | 主键 | +| batch | ForeignKey(FileSummaryBatch) | 所属批次 | +| original_name | CharField | 原始文件名 | +| storage_path | CharField | 保存路径 | +| file_size | BigInteger | 文件大小 | +| content_type | CharField | MIME 类型 | +| created_at | DateTimeField | 上传时间 | + +### 5.3 FileSummaryItem + +文件明细记录。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| id | BigAutoField | 主键 | +| batch | ForeignKey(FileSummaryBatch) | 所属批次 | +| file_index | Integer | 文件序号 | +| directory_level | CharField | 目录层级 | +| file_name | CharField | 文件名 | +| file_type | CharField | 文件类型 | +| relative_path | CharField | 相对路径 | +| storage_path | CharField | 实际处理路径 | +| page_count | Integer | 页数,可为空 | +| statistics_status | CharField | success、failed、unsupported、skipped | +| retry_count | Integer | 页数统计重试次数 | +| error_message | TextField | 异常说明 | +| created_at | DateTimeField | 创建时间 | +| updated_at | DateTimeField | 更新时间 | + +### 5.4 WorkflowNodeRun + +工作流节点运行记录。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| id | BigAutoField | 主键 | +| batch | ForeignKey(FileSummaryBatch) | 所属批次 | +| node_code | CharField | 节点编码 | +| node_name | CharField | 节点名称 | +| status | CharField | pending、running、retrying、success、failed、skipped | +| progress | Integer | 进度百分比 | +| message | TextField | 节点提示 | +| started_at | DateTimeField | 开始时间 | +| finished_at | DateTimeField | 完成时间 | + +### 5.5 ExportedSummaryFile + +导出文件记录。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| id | BigAutoField | 主键 | +| batch | ForeignKey(FileSummaryBatch) | 所属批次 | +| export_type | CharField | markdown、excel | +| file_name | CharField | 文件名 | +| storage_path | CharField | 保存路径 | +| download_url | CharField | 下载链接 | +| status | CharField | success、failed | +| error_message | TextField | 导出异常说明 | +| created_at | DateTimeField | 生成时间 | + +--- + +## 六、后端接口设计 + +### 6.1 上传并启动工作流 + +| 项目 | 内容 | +| --- | --- | +| URL | POST /api/review-agent/conversations/{conversation_id}/file-summary/start/ | +| 认证 | 登录用户 | +| 请求类型 | multipart/form-data | +| 请求参数 | files[]、prompt | +| 响应 | batch_id、status、workflow_nodes | + +处理逻辑: + +```text +校验 conversation 属于当前用户 +-> 保存上传文件 +-> 创建 FileSummaryBatch +-> 创建 WorkflowNodeRun 初始节点 +-> 启动后台线程执行工作流 +-> 返回 batch_id 和初始节点状态 +``` + +### 6.2 工作流 SSE 事件流 + +| 项目 | 内容 | +| --- | --- | +| URL | GET /api/review-agent/file-summary/{batch_id}/events/ | +| 认证 | 登录用户 | +| 响应类型 | text/event-stream | + +事件类型: + +| 事件 | 说明 | +| --- | --- | +| workflow_started | 工作流开始 | +| node_started | 节点开始 | +| node_progress | 节点进度更新 | +| node_retrying | 单文件解析重试 | +| node_completed | 节点完成 | +| node_failed | 节点失败 | +| workflow_completed | 工作流完成 | +| workflow_failed | 工作流失败 | + +示例: + +```json +{ + "batch_id": 12, + "node_code": "page_count", + "node_name": "解析页数中", + "status": "retrying", + "progress": 42, + "message": "检测报告.pdf 解析失败,正在第 2 次重试" +} +``` + +### 6.3 查询批次状态 + +| 项目 | 内容 | +| --- | --- | +| URL | GET /api/review-agent/file-summary/{batch_id}/ | +| 认证 | 登录用户 | +| 响应 | 批次摘要、节点状态、文件简表、导出文件链接 | + +用途: + +| 场景 | 说明 | +| --- | --- | +| 页面刷新恢复 | 前端重新加载后恢复工作流卡片状态 | +| 历史记录查看 | 从会话历史进入后展示已完成汇总结果 | + +### 6.4 下载导出文件 + +| 项目 | 内容 | +| --- | --- | +| URL | GET /api/review-agent/file-summary/exports/{export_id}/download/ | +| 认证 | 登录用户 | +| 响应 | 文件流 | + +权限规则: + +```text +export_id -> batch -> conversation -> user +必须等于当前登录用户,才允许下载。 +``` + +--- + +## 七、前端设计 + +### 7.1 AI 对话页改造 + +现有 `templates/home.html` 和 `static/js/app.js` 需要增强: + +| 改造点 | 说明 | +| --- | --- | +| 附件选择 | 在输入框旁增加文件上传按钮,支持压缩包和多个文件 | +| 工作流卡片 | 用户提交后在对话流中插入工作流状态卡片 | +| SSE 监听 | 监听后台节点事件,实时更新卡片节点状态 | +| Markdown 渲染 | AI 回复支持 Markdown 表格和下载链接渲染 | +| 状态恢复 | 页面刷新后查询批次状态,恢复工作流卡片 | + +### 7.2 工作流卡片 + +卡片包含节点列表: + +| 节点 | 前台展示文案 | +| --- | --- | +| upload | 上传中 | +| extract | 解压中 | +| inventory | 扫描中 | +| page_count | 解析页数中 | +| product_detect | 识别产品名中 | +| report/export | 输出中 | +| completed | 已完成 | + +节点状态样式: + +| 状态 | 展示 | +| --- | --- | +| pending | 灰色等待 | +| running | 高亮进行中 | +| retrying | 黄色重试中 | +| success | 绿色完成 | +| failed | 红色失败 | +| skipped | 灰色跳过 | + +### 7.3 对话框结果展示 + +工作流完成后,AI 对话框新增助手消息,内容为 Markdown: + +```markdown +已完成文件目录与页数汇总。 + +| 指标 | 数量 | +| --- | --- | +| 文件总数 | 24 | +| 统计成功 | 21 | +| 统计失败 | 2 | +| 不支持 | 1 | +| 总页数 | 386 | + +| 序号 | 目录层级 | 文件名 | 类型 | 页数 | 状态 | 异常说明 | +| --- | --- | --- | --- | --- | --- | --- | +| 1 | 注册资料/说明书 | 说明书.docx | docx | 12 | 成功 | | +| 2 | 注册资料/检测报告 | 检测报告.pdf | pdf | 38 | 成功 | | + +[下载 Markdown 报告](download-url) +[下载 Excel 明细](download-url) +``` + +--- + +## 八、后台服务设计 + +### 8.1 WorkflowExecutor + +负责批次级工作流编排。 + +| 方法 | 说明 | +| --- | --- | +| start(batch_id) | 启动后台任务 | +| run(batch_id) | 串行执行节点图 | +| run_node(node_code, context) | 执行单个节点 | +| emit_event(batch_id, event_type, payload) | 写入并推送事件 | +| complete(batch_id) | 完成批次 | +| fail(batch_id, error) | 标记批次失败 | + +### 8.2 SkillRegistry + +负责 Skill 注册与按需加载。 + +| 方法 | 说明 | +| --- | --- | +| register(name, skill_cls) | 注册 Skill | +| get(name) | 获取 Skill | +| run(name, context) | 执行 Skill | + +### 8.3 PageCountService + +负责具体文件页数统计。 + +| 方法 | 说明 | +| --- | --- | +| count_pdf(path) | 统计 PDF 页面数 | +| count_word(path) | doc/docx 转 PDF 后统计页面数 | +| count_excel(path) | 统计工作表数量 | +| count_ppt(path) | 统计幻灯片数量 | +| count_with_retry(item, max_retry=3) | 单文件重试统计 | + +### 8.4 ExportService + +负责 Markdown 和 Excel 导出。 + +| 方法 | 说明 | +| --- | --- | +| build_markdown_report(batch) | 生成完整 Markdown 报告 | +| build_chat_summary(batch) | 生成对话简表 | +| build_excel(batch) | 生成 Excel 明细 | +| create_download_record(batch, path, type) | 创建下载记录 | + +--- + +## 九、异常与重试设计 + +### 9.1 批次级失败 + +| 场景 | 处理 | +| --- | --- | +| 上传保存失败 | 批次不创建或标记失败 | +| 压缩包无法解压 | 批次失败,工作流终止 | +| 文件清单为空 | 批次失败,提示未检测到可处理文件 | +| 报告导出失败 | 批次失败或标记导出异常 | + +### 9.2 文件级失败 + +| 场景 | 处理 | +| --- | --- | +| 单文件页数解析失败 | 最多重试 3 次 | +| 重试仍失败 | statistics_status=failed,记录异常说明,继续处理其他文件 | +| 不支持类型 | statistics_status=unsupported,不重试 | +| 加密或损坏文件 | statistics_status=failed,记录“文件加密或损坏” | + +--- + +## 十、安全设计 + +| 设计点 | 说明 | +| --- | --- | +| 对话隔离 | 所有批次查询和下载必须校验 conversation.user | +| 防串文件 | 工作流只能读取当前 batch 通过 FileSummaryBatchAttachment 绑定的 FileAttachment | +| 解压安全 | 禁止压缩包内路径跳出批次工作目录 | +| 文件执行安全 | 不执行上传文件中的脚本、宏或外部链接 | +| 下载权限 | 下载接口必须验证当前用户拥有批次所属对话 | +| 存储隔离 | 按 user_id/conversation_id/batch_id 建立存储目录 | + +--- + +## 十一、验收设计 + +| 序号 | 验收项 | 验收标准 | +| --- | --- | --- | +| 1 | 对话绑定 | A 对话上传的文件不会出现在 B 对话的汇总结果中 | +| 2 | 压缩包处理 | 支持 zip、rar、7z 常见压缩包解压并保留目录结构 | +| 3 | 多文件处理 | 支持一次上传多个散装文件并生成同一批次结果 | +| 4 | 工作流卡片 | 前台能实时展示上传中、解压中、扫描中、解析中、输出中、完成状态 | +| 5 | 解析重试 | 单文件解析失败最多重试 3 次,失败后记录异常并继续 | +| 6 | Markdown 展示 | 对话框能正确渲染 Markdown 表格和下载链接 | +| 7 | 导出下载 | Markdown 报告和 Excel 明细可通过对话框链接下载 | +| 8 | 数据存档 | 数据库保留批次、上传文件、节点状态、文件明细、导出文件记录 | +| 9 | 标题更新 | 识别到产品名后,可将会话标题更新为“产品名-文件汇总” | + +--- + +## 十二、待确认事项 + +| 序号 | 问题 | 当前建议 | 状态 | +| --- | --- | --- | --- | +| 1 | 是否接入真实 LangGraph 依赖 | Demo 先按 LangGraph 节点图思想自实现轻量编排器 | 待确认 | +| 2 | rar/7z 解压依赖 | 可选 py7zr、rarfile、系统 7z 命令 | 待技术验证 | +| 3 | doc/docx 转 PDF 依赖 | 建议使用 LibreOffice headless | 待技术验证 | +| 4 | 用户手动命名对话时是否允许覆盖 | 建议不覆盖,仅写入产品名字段 | 待确认 | +| 5 | 后台任务是否需要取消能力 | Demo 可不做,正式版建议支持取消 | 待确认 | + +--- + +## 十三、实施建议 + +1. 先补充数据模型和迁移,建立批次、文件明细、节点状态和导出文件表。 +2. 增加上传并启动工作流接口,确保文件和当前对话强绑定。 +3. 实现轻量 WorkflowExecutor 和 SkillRegistry,先完成 zip、pdf、xlsx、pptx 的主链路。 +4. 改造前端对话框,增加附件上传、工作流卡片和 Markdown 渲染。 +5. 补齐 doc/docx、rar、7z 等依赖能力,再完善异常重试和下载权限测试。 diff --git a/docs/2.功能设计/2.NMPA注册资料法规核查与整改闭环.md b/docs/2.功能设计/2.NMPA注册资料法规核查与整改闭环.md new file mode 100644 index 0000000..a63d303 --- /dev/null +++ b/docs/2.功能设计/2.NMPA注册资料法规核查与整改闭环.md @@ -0,0 +1,730 @@ +# NMPA 注册资料法规核查与整改闭环工作流功能设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/2.NMPA注册资料法规核查与整改闭环.md | +| 依赖功能设计 | docs/2.功能设计/1.自动汇总.md | +| 功能名称 | NMPA 注册资料法规核查与整改闭环 | +| 所属模块 | 审核智能体 review_agent | +| 设计日期 | 2026-06-06 | +| 设计版本 | V1.0 | + +--- + +## 一、设计目标 + +本功能在“自动汇总文件夹文件目录与页数流程”基础上扩展,不重复实现上传、解压、文件扫描、页数统计、基础导出和 SSE 推送能力。法规核查工作流复用已有 `FileSummaryBatch` 和 `FileSummaryItem` 作为资料清单输入,新增法规规则库、RAG 法规依据索引、法规核查批次、风险问题、过程产物、飞书通知和整改复核能力。 + +工作流支持两种启动方式:用户可以在已有文件汇总批次完成后发起法规核查;也可以直接在上传资料后发起法规核查,系统内部先执行自动汇总,再串联执行法规核查。若同一对话已存在最近一次成功的文件汇总批次,默认复用该批次。 + +前端需要新增独立的法规核查工作流卡片。一个对话内可能同时存在“文件汇总”和“法规核查”等多个工作流卡片,卡片区域采用类似轮播图的切换方式展示当前活跃卡片和历史卡片。底层 SSE 事件机制复用现有 `workflow` 事件,通过 `workflow_type` 区分 `file_summary` 与 `regulatory_review`。 + +--- + +## 二、与自动汇总功能的关系 + +### 2.1 复用边界 + +| 能力 | 处理方式 | 说明 | +| --- | --- | --- | +| 上传接收 | 复用 | 沿用 `FileAttachment`、`FileSummaryBatchAttachment` 和上传接收接口 | +| 压缩包解压 | 复用 | 沿用自动汇总的解压 Skill 和工作目录规则 | +| 文件清单扫描 | 复用 | 以 `FileSummaryItem` 作为法规核查文件清单 | +| 页数统计 | 复用 | 法规核查直接读取页数和解析状态 | +| 基础节点状态 | 复用 | 沿用 `WorkflowNodeRun` 事件模型,新增 workflow_type | +| Markdown/Excel 下载 | 部分复用 | 最终报告进入 `ExportedSummaryFile`,过程产物进入 `RegulatoryArtifact` | +| 产品名识别 | 不扩展 | 原 `产品信息识别 Skill` 继续只服务自动汇总标题识别 | + +### 2.2 新增边界 + +| 能力 | 说明 | +| --- | --- | +| 法规适用信息抽取 | 新增 `RegulatoryInfoExtract Skill`,抽取注册类型、临床评价路径、产品关键信息 | +| 适用条件确认 | 通过 AI 对话选择框让用户确认或自由补充 | +| 规则文件与 RAG | 结构化规则文件负责判断,RAG 负责法规依据引用和解释 | +| 法规核查批次 | 新增 `RegulatoryReviewBatch`,关联 `FileSummaryBatch` | +| 风险问题与整改状态 | 新增 `RegulatoryIssue`,记录问题、风险、证据、责任人、状态 | +| 过程产物留底 | 新增 `RegulatoryArtifact`,保存条件确认、核查矩阵、风险清单、复核记录等 | +| 飞书通知 | 新增 `FeishuNotifier` 抽象接口,Demo 实现接飞书 CLI | + +--- + +## 三、总体架构 + +### 3.1 架构原则 + +| 原则 | 说明 | +| --- | --- | +| 依赖汇总批次 | 法规核查必须绑定一个 `FileSummaryBatch`,不能跨对话读取文件 | +| 工作流独立 | 法规核查拥有独立卡片、批次和节点,但事件通道可复用 | +| 规则优先 | 合规判断以结构化规则文件为准,RAG 只做法规依据检索和解释增强 | +| 人工确认 | 适用条件缺失时停在待确认,复核通过关闭前需要人工确认 | +| 过程留底 | 所有关键过程文档都要留底,便于复核和 Demo 展示 | +| 通知可替换 | 飞书发送通过接口抽象,Demo 接 CLI,后续可替换为 Webhook/API | + +### 3.2 逻辑架构 + +```mermaid +flowchart TD + A["AI 对话页"] --> B["工作流卡片轮播区"] + A --> C["法规核查启动接口"] + C --> D{"是否已有成功 FileSummaryBatch"} + D -->|"有"| E["复用最近成功汇总批次"] + D -->|"无"| F["串联执行自动汇总工作流"] + F --> E + E --> G["RegulatoryReviewBatch"] + G --> H["RegulatoryWorkflowExecutor"] + H --> I["SkillRegistry"] + I --> I1["RegulatoryInfoExtract Skill"] + I --> I2["TextExtract Skill"] + I --> I3["CompletenessCheck Skill"] + I --> I4["StructureCheck Skill"] + I --> I5["ConsistencyCheck Skill"] + I --> I6["RiskAssess Skill"] + I --> I7["RegulatoryReportExport Skill"] + H --> J["结构化规则文件"] + H --> K["本地法规 RAG 索引"] + H --> L["RegulatoryIssue"] + H --> M["RegulatoryArtifact"] + H --> N["FeishuNotifier CLI"] + H --> O["workflow SSE 事件"] + O --> B +``` + +### 3.3 技术选型 + +| 设计项 | Demo 方案 | 后续演进 | +| --- | --- | --- | +| 工作流编排 | 复用轻量 WorkflowExecutor 思路,新增 RegulatoryWorkflowExecutor | 接入 LangGraph 子图 | +| 事件机制 | 复用 `workflow` SSE,新增 `workflow_type=regulatory_review` | 独立工作流事件中心 | +| 规则存储 | 项目内 JSON/YAML 规则文件 | 规则管理后台 + 数据库版本表 | +| 法规依据检索 | 本地 CMDE 文档构建 RAG 索引 | 法规资料定期更新和重建索引 | +| 文本抽取 | 新增统一 TextExtract Skill | 建立文档文本缓存和 OCR 能力 | +| 飞书通知 | `FeishuNotifier` 接飞书 CLI,可直接测试发送 | 飞书开放平台 Webhook/API | +| 过程产物 | Markdown、Excel、JSON 留底 | 对象存储 + 证据快照管理 | + +--- + +## 四、工作流设计 + +### 4.1 启动方式 + +| 场景 | 处理方式 | +| --- | --- | +| 已有成功文件汇总批次 | 默认复用当前对话最近一次成功 `FileSummaryBatch` | +| 无成功文件汇总批次 | 系统先串联执行自动汇总,再执行法规核查 | +| 用户修改适用条件后重核 | 创建新的 `RegulatoryReviewBatch`,保留旧批次记录 | +| 用户补充缺失文件复核 | 通过对话指令上传补充文件,合并到原问题上下文后复核 | + +### 4.2 节点图 + +```mermaid +flowchart LR + N1["准备资料"] --> N2["识别信息"] + N2 --> N3{"适用条件是否完整"} + N3 -->|"否"| W["待用户确认"] + W --> N4["裁剪规则"] + N3 -->|"是"| N4 + N4 --> N5["完整性核查"] + N5 --> N6["文本抽取"] + N6 --> N7["章节核查"] + N6 --> N8["一致性核查"] + N7 --> N9["风险分级"] + N8 --> N9 + N9 --> N10["报告导出"] + N10 --> N11["飞书通知"] + N11 --> N12["待整改复核"] + N12 --> N13["完成"] + N4 -->|"规则加载失败"| R["RAG 辅助核查"] + R --> N9 +``` + +### 4.3 主节点与子节点 + +法规核查卡片展示主节点,主节点可展开查看子节点。 + +| 主节点 | 子节点 | 说明 | +| --- | --- | --- | +| 准备资料 | 复用批次、检查文件清单、读取规则版本 | 绑定 `FileSummaryBatch` | +| 识别信息 | 产品信息抽取、适用条件识别 | 生成用户确认项 | +| 确认条件 | 对话选择框确认、自由补充 | 卡片只展示等待状态 | +| 法规核查 | 规则裁剪、完整性核查、文本抽取、章节核查、一致性核查 | 完整性先跑,章节和一致性并行 | +| 风险输出 | 风险分级、建议生成、RAG 依据引用、报告导出 | 生成问题和过程产物 | +| 通知复核 | 飞书通知、补充资料、整改复核、关闭确认 | 支持后续闭环 | + +### 4.4 节点定义 + +| 节点编码 | 节点名称 | 触发 Skill/服务 | 成功条件 | 失败或暂停处理 | +| --- | --- | --- | --- | --- | +| prepare | 准备资料 | RegulatoryWorkflowExecutor | 绑定成功的 `FileSummaryBatch` | 无汇总批次则串联自动汇总 | +| info_extract | 识别信息 | RegulatoryInfoExtract Skill | 输出适用条件候选值 | 缺少关键条件则进入待确认 | +| condition_confirm | 确认条件 | Conversation Interaction | 用户确认产品类别、注册类型、临床路径等 | 暂停等待用户输入 | +| rule_scope | 裁剪规则 | RuleLoader | 生成本次适用规则清单 | 规则加载失败则降级 RAG 辅助核查 | +| completeness_check | 完整性核查 | CompletenessCheck Skill | 输出缺失文件和文件项问题 | 单项失败记录待确认 | +| text_extract | 文本抽取 | TextExtract Skill | 抽取关键文件文本和首页内容 | 单文件失败记录问题并继续 | +| structure_check | 章节核查 | StructureCheck Skill | 输出章节缺失和格式问题 | 与一致性核查并行 | +| consistency_check | 一致性核查 | ConsistencyCheck Skill | 输出字段冲突问题 | 低置信度字段可用 LLM 辅助 | +| risk_assess | 风险分级 | RiskAssess Skill | 归并问题、生成风险等级和建议 | 无 RAG 依据时仍输出规则问题 | +| report_export | 报告导出 | RegulatoryReportExport Skill | 生成 Markdown、Excel、JSON 产物 | 导出失败记录批次失败 | +| notify | 飞书通知 | FeishuNotifier | 阻断项、高风险、中风险完成通知 | CLI 失败写入通知失败记录 | +| rectify_review | 整改复核 | RectificationReview Skill | 输出复核通过/不通过 | 关闭前等待人工确认 | + +--- + +## 五、规则库与 RAG 设计 + +### 5.1 双层法规能力 + +| 层级 | 职责 | 不承担的职责 | +| --- | --- | --- | +| 结构化规则库 | 判断文件项、章节项、关键字段、一致性字段、风险等级和整改模板 | 不负责自由解释法规 | +| RAG 法规依据索引 | 从本地 CMDE 原文材料检索法规依据片段、来源文件和引用说明 | 不直接决定合规结论 | + +### 5.2 规则文件结构 + +Demo 阶段规则采用项目内 JSON/YAML 文件维护,建议路径: + +```text +review_agent/rules/nmpa_ivd_registration_v1.yaml +``` + +规则文件需要包含版本信息: + +| 字段 | 说明 | +| --- | --- | +| version | 规则版本,如 nmpa_ivd_2021_v1 | +| source_url | https://www.cmde.org.cn/xwdt/zxyw/20210930163300622.html | +| source_path | 本地 CMDE 法规材料路径 | +| effective_date | 规则生效日期或公告发布日期 | +| rag_index_version | 对应 RAG 索引版本 | + +规则项最小结构: + +```yaml +version: nmpa_ivd_2021_v1 +source_url: https://www.cmde.org.cn/xwdt/zxyw/20210930163300622.html +source_path: docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告 +effective_date: "2021-09-30" +file_items: + - rule_id: ivd_registration_test_report + title: 注册检验报告 + required_type: required + applies_when: + product_category: in_vitro_diagnostic + registration_type: initial_registration + match_keywords: + file_name: ["注册检验报告", "检验报告"] + directory: ["注册检验", "检测报告"] + first_pages: ["医疗器械注册检验报告", "检验结论"] + required_sections: ["样品信息", "检验依据", "检验项目", "检验结论", "签章"] + required_fields: ["产品名称", "型号规格", "样本类型"] + consistency_fields: ["产品名称", "型号规格"] + default_risk_level: blocking + suggestion_template: 请补充与本产品一致的注册检验报告,并确保报告包含样品信息、检验依据、检验项目、检验结论和签章页。 +``` + +### 5.3 规则加载策略 + +| 场景 | 处理方式 | +| --- | --- | +| 规则文件正常加载 | 按结构化规则执行核查,RAG 补充法规依据 | +| 规则文件加载失败 | 降级为 RAG 辅助核查,报告明确标记“仅供参考,不输出正式合规结论” | +| 规则命中但 RAG 无依据 | 仍输出问题,法规依据标记“规则库依据,原文待补充” | +| 规则版本与 RAG 版本不一致 | 批次标记提示项,允许继续但报告记录版本差异 | + +### 5.4 RAG 索引设计 + +| 项目 | 说明 | +| --- | --- | +| 资料来源 | 本地 CMDE 公告目录下的 doc/docx 文档 | +| 索引粒度 | 按标题、段落、表格行或文件项说明切分 | +| 元数据 | source_file、section_title、page_or_row、rule_version、source_url | +| 输出 | matched_snippets、source_file、score、citation_text | +| 用途 | 风险报告中的法规依据、AI 对话解释、飞书通知简要依据 | + +--- + +## 六、Skill 设计 + +### 6.1 RegulatoryInfoExtract Skill + +| 项目 | 说明 | +| --- | --- | +| 中文名称 | 法规适用信息抽取 Skill | +| 职责 | 从 `FileSummaryItem`、文件名、目录名和文本片段中抽取法规适用条件 | +| 输入 | regulatory_batch_id、file_summary_batch_id、file_items | +| 输出 | 产品类别、注册类型、临床评价路径、产品名称、型号规格、预期用途、置信度、证据 | +| 关键规则 | 不修改自动汇总的产品名识别 Skill;缺少关键条件时暂停等待用户确认 | + +用户确认字段: + +| 字段 | 是否必填 | 说明 | +| --- | --- | --- | +| 产品类别 | 是 | 医疗器械/体外诊断试剂等 | +| 注册类型 | 是 | 首次注册、变更注册、延续注册等 | +| 临床评价路径 | 是 | 临床试验、免临床、同品种比对等 | +| 产品名称 | 是 | 用于一致性核查 | +| 型号规格 | 是 | 用于一致性核查 | +| 预期用途 | 是 | 用于规则裁剪和一致性核查 | + +### 6.2 TextExtract Skill + +| 项目 | 说明 | +| --- | --- | +| 中文名称 | 文本抽取 Skill | +| 职责 | 按需抽取关键文件首页、前几页、章节文本和字段候选值 | +| 输入 | regulatory_batch_id、file_item_ids、extract_scope | +| 输出 | text_blocks、first_page_text、section_candidates、field_candidates | +| 数据写入 | RegulatoryArtifact,artifact_type=text_extract_json | +| 关键规则 | 统一抽取,避免完整性、章节、一致性节点重复读取文件 | + +### 6.3 CompletenessCheck Skill + +| 项目 | 说明 | +| --- | --- | +| 中文名称 | 法规资料完整性核查 Skill | +| 职责 | 对照适用规则清单检查文件项和文件项子项是否存在 | +| 输入 | regulatory_batch_id、file_summary_items、scoped_rules | +| 输出 | missing_items、matched_items、pending_confirm_items | +| 关键规则 | 文件匹配采用文件名、目录名、首页内容三层匹配,记录命中证据 | + +### 6.4 StructureCheck Skill + +| 项目 | 说明 | +| --- | --- | +| 中文名称 | 章节结构核查 Skill | +| 职责 | 检查关键文件是否包含规则要求章节 | +| 输入 | regulatory_batch_id、text_blocks、required_sections | +| 输出 | missing_sections、abnormal_sections、evidence | +| 关键规则 | 章节缺失按规则初始等级输出,由 RiskAssess 统一归并 | + +### 6.5 ConsistencyCheck Skill + +| 项目 | 说明 | +| --- | --- | +| 中文名称 | 跨文件一致性核查 Skill | +| 职责 | 抽取并比对产品名称、型号规格、预期用途等核心字段 | +| 输入 | regulatory_batch_id、text_blocks、consistency_fields | +| 输出 | field_values、conflicts、confidence | +| 关键规则 | 规则/正则优先,失败或置信度低时调用 LLM 辅助抽取结构化 JSON | + +### 6.6 RiskAssess Skill + +| 项目 | 说明 | +| --- | --- | +| 中文名称 | 风险分级与整改建议 Skill | +| 职责 | 归并核查问题,统一风险等级,生成整改建议和法规依据 | +| 输入 | all_check_findings、rules、rag_results | +| 输出 | RegulatoryIssue 列表、risk_summary、suggestions | +| 关键规则 | 核查节点提供初始等级,RiskAssess 负责去重、合并、升级或降级 | + +### 6.7 RegulatoryReportExport Skill + +| 项目 | 说明 | +| --- | --- | +| 中文名称 | 法规核查报告导出 Skill | +| 职责 | 生成最终报告和过程产物 | +| 输入 | regulatory_batch_id、issues、artifacts、notification_records | +| 输出 | Markdown 报告、Excel 清单、JSON 产物、下载链接 | +| 关键规则 | 最终报告进入 `ExportedSummaryFile`,过程产物进入 `RegulatoryArtifact` | + +### 6.8 FeishuNotifier + +| 项目 | 说明 | +| --- | --- | +| 中文名称 | 飞书通知适配器 | +| 职责 | 对阻断项、高风险、中风险发送飞书通知并 @ 上传人 | +| 输入 | recipient、risk_summary、message_markdown | +| 输出 | send_status、external_message_id、error_message | +| Demo 实现 | 抽象接口接飞书 CLI,并支持直接测试发送 | +| 后续演进 | 替换为飞书 Webhook/API | + +--- + +## 七、数据模型设计 + +### 7.1 RegulatoryReviewBatch + +法规核查批次,表示一次法规核查工作流执行。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| id | BigAutoField | 主键 | +| conversation | ForeignKey(Conversation) | 绑定对话 | +| user | ForeignKey(User) | 发起用户 | +| file_summary_batch | ForeignKey(FileSummaryBatch) | 关联文件汇总批次 | +| batch_no | CharField | 法规核查批次编号 | +| status | CharField | pending、running、waiting_user、success、failed、reference_only、partial_success、cancelled | +| rule_version | CharField | 使用的结构化规则版本 | +| rule_source_url | URLField | 法规来源 URL | +| rule_source_path | CharField | 本地法规资料路径 | +| rag_index_version | CharField | RAG 索引版本 | +| product_category | CharField | 用户确认后的产品类别 | +| registration_type | CharField | 用户确认后的注册类型 | +| clinical_evaluation_path | CharField | 用户确认后的临床评价路径 | +| product_name | CharField | 产品名称 | +| model_specification | CharField | 型号规格 | +| intended_use | TextField | 预期用途 | +| risk_summary_json | JSONField | 风险数量摘要 | +| error_message | TextField | 异常说明 | +| created_at | DateTimeField | 创建时间 | +| started_at | DateTimeField | 开始时间 | +| finished_at | DateTimeField | 完成时间 | + +### 7.2 RegulatoryIssue + +法规核查问题和整改状态实体。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| id | BigAutoField | 主键 | +| batch | ForeignKey(RegulatoryReviewBatch) | 所属法规核查批次 | +| issue_code | CharField | 问题编码 | +| issue_type | CharField | completeness、structure、consistency、notification、review | +| risk_level | CharField | blocking、high、medium、low、info | +| title | CharField | 问题标题 | +| description | TextField | 问题描述 | +| rule_id | CharField | 命中的规则 ID | +| regulation_basis | TextField | 法规依据或规则依据 | +| evidence_json | JSONField | 文件路径、页码、文本片段、字段值等证据 | +| suggestion | TextField | 整改建议 | +| owner | ForeignKey(User) | 默认上传人 | +| status | CharField | 待确认、待处理、已补充、复核通过、复核不通过、已关闭 | +| confirmed_by | ForeignKey(User) | 确认人,可为空 | +| closed_by | ForeignKey(User) | 关闭人,可为空 | +| created_at | DateTimeField | 创建时间 | +| updated_at | DateTimeField | 更新时间 | + +### 7.3 RegulatoryArtifact + +法规核查过程产物留底实体。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| id | BigAutoField | 主键 | +| batch | ForeignKey(RegulatoryReviewBatch) | 所属法规核查批次 | +| artifact_type | CharField | condition_record、rule_matrix、risk_list、text_extract_json、rag_result_json、notification_record、review_record | +| file_format | CharField | markdown、excel、json | +| file_name | CharField | 文件名 | +| storage_path | CharField | 存储路径 | +| summary | TextField | 产物摘要 | +| created_at | DateTimeField | 创建时间 | + +### 7.4 RegulatoryNotificationRecord + +飞书通知记录。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| id | BigAutoField | 主键 | +| batch | ForeignKey(RegulatoryReviewBatch) | 所属法规核查批次 | +| recipient | ForeignKey(User) | 通知对象 | +| channel | CharField | feishu_cli、feishu_api、mock | +| risk_levels | JSONField | 本次通知包含的风险等级 | +| message_summary | TextField | 通知摘要 | +| send_status | CharField | pending、success、failed | +| external_message_id | CharField | 外部消息 ID,可为空 | +| error_message | TextField | 失败原因 | +| sent_at | DateTimeField | 发送时间 | + +### 7.5 与既有模型关系 + +```text +Conversation 1:N FileSummaryBatch +FileSummaryBatch 1:N FileSummaryItem +FileSummaryBatch 1:N RegulatoryReviewBatch +RegulatoryReviewBatch 1:N RegulatoryIssue +RegulatoryReviewBatch 1:N RegulatoryArtifact +RegulatoryReviewBatch 1:N RegulatoryNotificationRecord +RegulatoryReviewBatch 1:N ExportedSummaryFile +``` + +--- + +## 八、接口设计 + +### 8.1 发起法规核查 + +| 项目 | 内容 | +| --- | --- | +| URL | POST /api/review-agent/regulatory-review/start/ | +| 认证 | 登录用户 | +| 请求 | conversation_id、file_summary_batch_id 可选、force_resummary 可选 | +| 响应 | regulatory_batch_id、workflow_type、status | + +处理规则: + +| 场景 | 说明 | +| --- | --- | +| 传入 file_summary_batch_id | 校验该批次属于当前对话和用户 | +| 未传入 file_summary_batch_id | 默认查找当前对话最近一次成功汇总批次 | +| 无成功汇总批次 | 自动启动文件汇总工作流,完成后继续法规核查 | + +### 8.2 提交适用条件确认 + +| 项目 | 内容 | +| --- | --- | +| URL | POST /api/review-agent/regulatory-review/{batch_id}/confirm-condition/ | +| 认证 | 登录用户 | +| 请求 | product_category、registration_type、clinical_evaluation_path、product_name、model_specification、intended_use | +| 响应 | batch_id、status、next_node | + +说明:对话选择框负责收集用户确认结果,接口只接收结构化确认值。用户修改已确认条件时创建新的 `RegulatoryReviewBatch`。 + +### 8.3 查询法规核查状态 + +| 项目 | 内容 | +| --- | --- | +| URL | GET /api/review-agent/regulatory-review/{batch_id}/ | +| 认证 | 登录用户 | +| 响应 | 批次状态、主节点状态、风险摘要、导出文件、过程产物 | + +用途: + +| 场景 | 说明 | +| --- | --- | +| 页面刷新恢复 | 恢复法规核查卡片状态 | +| 卡片轮播切换 | 切换历史工作流卡片时加载详情 | +| 整改复核 | 查看待处理和待确认问题 | + +### 8.4 发起整改复核 + +| 项目 | 内容 | +| --- | --- | +| URL | POST /api/review-agent/regulatory-review/{batch_id}/rectify-review/ | +| 认证 | 登录用户 | +| 请求 | issue_ids、uploaded_files 可选、review_mode | +| 响应 | review_record_id、status、updated_issues | + +Demo 阶段主要通过对话指令触发,卡片入口作为设计预留。 + +### 8.5 下载法规核查文件 + +| 项目 | 内容 | +| --- | --- | +| URL | GET /api/review-agent/regulatory-review/artifacts/{artifact_id}/download/ | +| 认证 | 登录用户 | +| 响应 | 文件流 | + +权限规则: + +```text +artifact_id -> regulatory_batch -> conversation -> user +必须等于当前登录用户,才允许下载。 +``` + +--- + +## 九、前端设计 + +### 9.1 多工作流卡片轮播 + +AI 对话页顶部或对话流内的工作流区域支持多个工作流卡片。 + +| 设计点 | 说明 | +| --- | --- | +| 展示方式 | 顶部只显示当前活跃卡片,通过左右箭头或点位切换历史卡片 | +| 卡片类型 | file_summary、regulatory_review | +| 事件更新 | 统一监听 `workflow` SSE,根据 workflow_type 和 batch_id 更新对应卡片 | +| 卡片职责 | 展示工作流状态,不承载适用条件编辑表单 | +| 历史恢复 | 页面刷新后按对话查询工作流批次并恢复卡片列表 | + +### 9.2 法规核查卡片 + +卡片主节点: + +| 主节点 | 展示文案 | +| --- | --- | +| prepare | 准备资料 | +| info_extract | 识别信息 | +| condition_confirm | 确认条件 | +| regulatory_check | 法规核查 | +| risk_output | 风险输出 | +| notify_review | 通知复核 | +| completed | 已完成 | + +节点可展开展示子节点,例如法规核查下展开“规则裁剪、完整性核查、文本抽取、章节核查、一致性核查”。 + +### 9.3 适用条件确认交互 + +适用条件确认采用 AI 对话选择框,不放在卡片内。交互形式参考计划模式:系统给出识别结果、推荐选项和自由输入能力。 + +确认字段: + +| 字段 | 交互方式 | +| --- | --- | +| 产品类别 | 选项 + 自由输入 | +| 注册类型 | 选项 + 自由输入 | +| 临床评价路径 | 选项 + 自由输入 | +| 产品名称 | 文本确认 | +| 型号规格 | 文本确认 | +| 预期用途 | 文本确认 | + +### 9.4 对话框结果展示 + +工作流完成后新增助手消息,优先展示风险摘要、待处理问题和下载链接。 + +```markdown +已完成 NMPA 注册资料法规核查。 + +| 风险等级 | 数量 | +| --- | --- | +| 阻断项 | 2 | +| 高风险 | 1 | +| 中风险 | 3 | +| 低风险 | 4 | +| 提示项 | 2 | + +| 等级 | 问题 | 状态 | 建议 | +| --- | --- | --- | --- | +| 阻断项 | 缺少注册检验报告 | 待处理 | 请补充注册检验报告并发起复核 | + +[下载 Markdown 核查报告](download-url) +[下载 Excel 缺失清单](download-url) +[下载过程产物 JSON](download-url) +``` + +--- + +## 十、事件设计 + +### 10.1 SSE 事件结构 + +复用现有 `workflow` 事件,新增字段区分工作流。 + +```json +{ + "event": "workflow", + "workflow_type": "regulatory_review", + "batch_id": 2001, + "conversation_id": 1001, + "node_code": "structure_check", + "node_group": "regulatory_check", + "status": "running", + "message": "正在核查关键文件章节结构", + "progress": 62, + "payload": { + "risk_summary": { + "blocking": 1, + "high": 2 + } + } +} +``` + +### 10.2 状态扩展 + +| 状态 | 含义 | +| --- | --- | +| pending | 已创建,等待执行 | +| running | 执行中 | +| waiting_user | 等待用户确认适用条件 | +| success | 节点成功 | +| failed | 节点失败 | +| reference_only | 规则库不可用,降级为 RAG 辅助核查 | +| partial_success | 部分节点、通知或非关键过程产物失败,但已输出主要结果 | +| cancelled | 用户或系统取消执行 | +| skipped | 当前节点跳过 | + +--- + +## 十一、输出与留底设计 + +### 11.1 最终下载文件 + +最终面向用户下载的报告沿用 `ExportedSummaryFile`。 + +| 文件 | 说明 | +| --- | --- | +| Markdown 核查报告 | 面向人工阅读的完整法规核查报告 | +| Excel 缺失清单 | 面向整改跟踪的风险和缺失清单 | +| JSON 结果包 | 面向后续复核和系统处理的结构化结果 | + +### 11.2 过程产物 + +过程产物进入 `RegulatoryArtifact`。 + +| 产物类型 | 格式 | 说明 | +| --- | --- | --- | +| condition_record | markdown/json | 适用条件识别和用户确认记录 | +| rule_matrix | excel/json | 法规核查矩阵 | +| risk_list | markdown/json | 风险清单和等级归并结果 | +| text_extract_json | json | 关键文件文本抽取结果 | +| rag_result_json | json | RAG 检索依据和引用片段 | +| notification_record | markdown/json | 飞书通知记录 | +| review_record | markdown/json | 整改复核记录 | + +--- + +## 十二、异常与降级设计 + +| 场景 | 处理 | +| --- | --- | +| 无成功文件汇总批次 | 自动串联执行文件汇总;汇总失败则法规核查不启动 | +| 规则文件加载失败 | 降级为 RAG 辅助核查,标记 `reference_only`,报告声明仅供参考 | +| RAG 检索不到依据 | 规则命中的问题仍输出,依据标记“规则库依据,原文待补充” | +| 关键适用条件缺失 | 工作流进入 `waiting_user`,用户确认后继续 | +| 文本抽取失败 | 记录文件级问题,相关章节或一致性结果标记待确认 | +| LLM 字段抽取失败 | 回退规则/正则结果,低置信度字段进入待确认 | +| 飞书 CLI 发送失败 | 记录通知失败,不阻断报告生成 | +| 过程产物导出失败 | 批次标记失败或部分失败,错误信息写入批次 | + +--- + +## 十三、安全设计 + +| 设计点 | 说明 | +| --- | --- | +| 对话隔离 | RegulatoryReviewBatch 必须绑定当前 Conversation | +| 文件访问 | 只能读取关联 FileSummaryBatch 下的 FileSummaryItem | +| 下载权限 | 导出文件和过程产物下载必须校验 conversation.user | +| 飞书脱敏 | 飞书通知只展示风险摘要和必要文件名,不直接发送敏感全文 | +| 证据留痕 | 证据片段写入受控存储,不暴露给无权限用户 | +| CLI 安全 | 飞书 CLI 参数使用结构化调用,避免拼接执行用户输入 | + +--- + +## 十四、验收设计 + +| 序号 | 验收项 | 验收标准 | +| --- | --- | --- | +| 1 | 汇总复用 | 已有成功文件汇总批次时,法规核查默认复用最近批次 | +| 2 | 串联启动 | 无成功汇总批次时,可先自动汇总再执行法规核查 | +| 3 | 多卡片切换 | 同一对话存在多个工作流时,可通过轮播切换卡片 | +| 4 | 适用条件确认 | 系统能识别条件并通过对话选择框让用户确认 | +| 5 | 规则与 RAG | 结构化规则负责判断,RAG 能补充法规依据 | +| 6 | 完整性核查 | 能识别缺失文件和文件项级缺失 | +| 7 | 章节核查 | 能识别关键文件章节缺失或异常 | +| 8 | 一致性核查 | 能识别产品名称、型号规格、预期用途等字段冲突 | +| 9 | 风险分级 | 问题能归并为阻断项、高、中、低、提示项 | +| 10 | 飞书通知 | 阻断项、高风险、中风险能通过飞书 CLI @ 上传人 | +| 11 | 过程留底 | 条件确认、核查矩阵、风险清单、RAG 结果、通知记录、复核记录均有产物 | +| 12 | 整改复核 | 用户通过对话指令上传补充资料后,可重新复核问题状态 | +| 13 | 权限隔离 | A 对话的法规核查结果和过程产物不能被 B 对话访问 | + +--- + +## 十五、实施建议 + +1. 先实现 `RegulatoryReviewBatch`、`RegulatoryIssue`、`RegulatoryArtifact`、`RegulatoryNotificationRecord` 数据模型。 +2. 增加规则文件加载器和一版 `nmpa_ivd_registration_v1` 结构化规则。 +3. 构建本地 CMDE 法规材料 RAG 索引,确保能按规则项检索依据。 +4. 实现法规核查工作流主链路:准备资料、信息抽取、条件确认、规则裁剪、完整性核查。 +5. 补齐 `TextExtract`、章节核查、一致性核查、风险归并和报告导出。 +6. 接入 `FeishuNotifier` CLI 实现并提供直接测试命令。 +7. 改造前端工作流卡片,支持 `workflow_type` 和轮播切换。 +8. 最后完善整改复核、过程产物下载和权限校验。 + +--- + +## 十六、待确认事项 + +| 序号 | 问题 | 当前建议 | 状态 | +| --- | --- | --- | --- | +| 1 | 规则文件格式使用 YAML 还是 JSON | 建议 YAML,便于人工维护和注释 | 待确认 | +| 2 | 本地 RAG 使用哪种向量库 | 可复用项目依赖中的 ChromaDB | 待技术验证 | +| 3 | 飞书 CLI 具体命令格式 | 需要结合本机飞书 CLI 或企业内部工具确认 | 待确认 | +| 4 | 对话选择框前端能力 | 参考计划模式实现选项 + 自由输入 | 待技术验证 | +| 5 | LLM 抽取是否需要人工确认阈值 | 建议低于置信度阈值进入待确认 | 待确认 | diff --git a/docs/2.功能设计/3.产品关键信息提取与申报文件自动填表.md b/docs/2.功能设计/3.产品关键信息提取与申报文件自动填表.md new file mode 100644 index 0000000..b4efdc4 --- /dev/null +++ b/docs/2.功能设计/3.产品关键信息提取与申报文件自动填表.md @@ -0,0 +1,816 @@ +# 产品关键信息提取与申报文件自动填表功能设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/3.产品关键信息提取与申报文件自动填表.md | +| 依赖功能设计 | docs/2.功能设计/1.自动汇总.md;docs/2.功能设计/2.NMPA注册资料法规核查与整改闭环.md | +| 功能名称 | 产品关键信息提取与申报文件自动填表 | +| 所属模块 | 审核智能体 review_agent | +| 设计日期 | 2026-06-07 | +| 设计版本 | V1.0 | + +--- + +## 一、设计目标 + +本功能作为独立工作流 `application_form_fill` 建设,由用户在 AI 对话中触发,例如“帮我填注册证”“给我这个内容对应的表格”“为我该方案生成申报模板”“生成安全和性能基本原则清单”“把产品信息填到申报模板里”等。用户可以明确指定目标模板;未指定时,系统根据识别出的注册类型生成当前注册类型适用的全部模板。 + +本功能复用第一批文件汇总结果作为文件来源,复用第二批法规核查中的文本抽取、适用条件识别、LLM 调用、飞书通知和导出下载能力,但拥有独立批次、独立工作流卡片和独立过程产物。系统复制原始法规模板到批次工作目录,不覆盖原始文件;随后按模板配置识别应填字段,使用规则/正则抽取与 LLM 结构化抽取并行处理,合并字段、识别冲突、写入 Word 模板,并在 AI 对话框和飞书通知中提示生成结果与冲突摘要。 + +Demo 阶段优先保证 Word 模板自动填写和下载。PDF 转换作为待办增强项:功能设计保留 PDF 导出节点和数据结构,实施时可先返回 Word 与追溯清单,并在待办清单记录 PDF 转换能力。 + +--- + +## 二、与既有功能的关系 + +### 2.1 复用边界 + +| 能力 | 处理方式 | 现有代码/模型 | +| --- | --- | --- | +| 对话与用户权限 | 复用 | `Conversation`、`Message` | +| 附件上传与文件绑定 | 复用 | `FileAttachment`、`FileSummaryBatchAttachment` | +| 文件汇总与页数统计 | 复用 | `FileSummaryBatch`、`FileSummaryItem`、`file_summary.workflow` | +| 文本抽取 | 复用并扩展 | `regulatory_review/services/text_extract.py`、`rag_index.py` | +| 适用条件候选 | 复用并扩展 | `regulatory_review/services/info_extract.py` | +| LLM 调用 | 复用 | `review_agent/llm.py`、`regulatory_review/services/llm_review.py` | +| 导出记录与下载 | 扩展复用 | `ExportedSummaryFile` | +| 过程产物 | 复用 | `RegulatoryArtifact` 或新增填表过程产物 | +| 飞书通知 | 复用并扩展 | `regulatory_review/services/feishu_notifier.py` | +| SSE 工作流事件 | 复用 | `WorkflowNodeRun`、`WorkflowEvent` | + +### 2.2 新增边界 + +| 能力 | 说明 | +| --- | --- | +| 独立填表批次 | 新增 `ApplicationFormFillBatch`,不强绑法规核查批次 | +| 模板配置 | 新增 YAML 配置,维护模板路径、适用条件、字段映射和输出规则 | +| 模板选择 | 根据用户指定模板和注册类型选择生成范围 | +| 规则/正则与 LLM 并行抽取 | 两路抽取并行执行,最后统一合并 | +| 字段冲突归并 | 按来源文件优先级处理,说明书优先;冲突字段高亮 | +| Word 模板填充 | 使用 `python-docx` 对 `.docx` 表格、段落和占位字段写入 | +| `.doc` 模板转换 | 使用 LibreOffice/soffice 或预转换 `.docx` 模板 | +| 字段来源追溯 | 输出 Excel/JSON 追溯清单,记录抽取、合并和冲突证据 | + +--- + +## 三、总体架构 + +### 3.1 架构原则 + +| 原则 | 说明 | +| --- | --- | +| 独立工作流 | 填表流程拥有独立批次、节点和卡片,workflow_type 为 `application_form_fill` | +| 复用文件汇总 | 填表不重新实现上传扫描,默认使用当前对话最近成功的 `FileSummaryBatch` | +| 用户指令优先 | 用户明确指定模板或注册类型时,优先使用用户指令 | +| 配置驱动 | 模板路径、字段映射、适用条件和输出规则写入 YAML 配置 | +| Word 优先 | Demo 阶段优先生成可编辑 Word,PDF 作为增强项进入待办 | +| 可追溯 | 规则抽取、LLM 抽取、合并结果、冲突列表和来源证据均留底 | +| 失败隔离 | 单字段、单模板或 PDF 转换失败不影响其他模板输出 | +| 通知可控 | 填表完成后可通过飞书通知上传人,通知内容只包含摘要和下载提示 | + +### 3.2 逻辑架构 + +```mermaid +flowchart TD + A["AI 对话页"] --> B["意图识别 application_form_fill"] + B --> C{"本次消息是否带附件"} + C -->|"是"| D["先执行文件汇总工作流"] + C -->|"否"| E["查找最近成功 FileSummaryBatch"] + D --> E + E --> F["ApplicationFormFillBatch"] + F --> G["FormFillWorkflowExecutor"] + G --> H["模板配置 YAML"] + G --> I["模板选择服务"] + G --> J["文本抽取服务"] + J --> K1["规则/正则抽取"] + J --> K2["LLM 结构化抽取"] + K1 --> L["字段合并与冲突归并"] + K2 --> L + L --> M["Word 模板填充服务"] + M --> N["追溯清单导出"] + M --> O["PDF 转换服务 P1"] + N --> P["ExportedSummaryFile"] + O --> P + G --> Q["WorkflowEvent/SSE"] + Q --> R["自动填表工作流卡片"] + G --> S["FeishuNotifier"] + S --> T["上传人通知"] +``` + +### 3.3 技术选型 + +| 设计项 | Demo 方案 | 后续演进 | +| --- | --- | --- | +| Web 框架 | Django,沿用当前 `review_agent` 应用 | 保持 Django,必要时拆分独立 app | +| 工作流编排 | 新增轻量 `FormFillWorkflowExecutor` | 接入 LangGraph 子图 | +| 后台执行 | Django 后台线程,沿用现有工作流方式 | Celery/RQ + Redis | +| 工作流状态 | `WorkflowNodeRun` + `WorkflowEvent`,新增 workflow_type | 独立工作流事件中心 | +| 模板配置 | YAML,建议路径 `review_agent/application_form_fill/templates/application_form_templates_v1.yaml` | 数据库模板管理后台 | +| Word 处理 | `python-docx` 写入 `.docx` 表格和段落,高亮冲突字段 | OOXML 精细 patch、内容控件 SDT | +| `.doc` 转换 | LibreOffice/soffice headless 转 `.docx`;无法部署时预置 `.docx` 工作模板 | 模板入库前统一转换和人工校验 | +| PDF 导出 | P1 待办:LibreOffice/soffice headless 转 PDF | 逐页渲染 QA、版式差异检测 | +| Excel 追溯清单 | `openpyxl` | 增加多 Sheet 审核视图 | +| 文本抽取 | 复用 `text_extract.py`、`rag_index.py` | OCR、文档文本缓存 | +| 字段抽取 | 规则/正则与 LLM 结构化抽取并行,合并后输出 | 可配置抽取器和置信度模型 | +| 飞书通知 | 复用 `FeishuNotifier`,Demo 可 mock 或 CLI | 飞书 Webhook/API | + +--- + +## 四、触发与模板选择设计 + +### 4.1 意图识别 + +填表工作流通过用户对话触发。意图识别可先采用关键词规则,必要时调用现有 LLM 路由能力。 + +| 触发表达 | 触发结果 | +| --- | --- | +| 帮我填注册证 | 触发填表,指定注册证格式 | +| 给我这个内容对应的表格 | 触发填表,未指定模板 | +| 为我该方案生成申报模板 | 触发填表,未指定模板 | +| 生成安全和性能基本原则清单 | 触发填表,指定安全和性能基本原则清单 | +| 把产品信息填到申报模板里 | 触发填表,未指定模板 | +| 只生成变更注册备案文件 | 触发填表,指定变更注册(备案)文件 | + +### 4.2 文件来源选择 + +| 场景 | 处理方式 | +| --- | --- | +| 本次消息带新附件 | 先自动执行文件汇总,汇总成功后启动填表 | +| 本次消息无附件 | 默认使用当前对话最近一次成功 `FileSummaryBatch` | +| 无成功汇总批次 | 对话框提示用户先上传资料或补充附件 | +| 用户明确指定历史批次 | 校验批次属于当前对话和当前用户后使用 | + +### 4.3 注册类型识别优先级 + +注册类型用于决定默认生成哪些模板。优先级如下: + +```text +用户话语明确指定 +-> 当前对话已确认的法规核查条件 +-> 上传文件内容抽取结果 +-> 无法识别 +``` + +### 4.4 模板选择规则 + +| 场景 | 生成模板 | +| --- | --- | +| 用户未指定模板,注册类型为首次注册 | 注册证格式;安全和性能基本原则清单 | +| 用户未指定模板,注册类型为变更注册或备案 | 变更注册(备案)文件;安全和性能基本原则清单 | +| 用户未指定模板,注册类型无法识别 | 安全和性能基本原则清单;注册证/变更文件进入待确认提示 | +| 用户明确指定模板且与注册类型一致 | 只生成用户指定模板 | +| 用户明确指定模板但与注册类型不一致 | 允许生成,并在摘要和追溯清单提示“与识别注册类型不一致,需人工确认” | +| 用户指定“全部模板” | 生成三个目标模板,并提示用户核对注册类型适用性 | + +--- + +## 五、工作流设计 + +### 5.1 节点图 + +```mermaid +flowchart LR + N1["准备资料"] --> N2["选择模板"] + N2 --> N3["复制模板"] + N3 --> N4["抽取字段"] + N4 --> N5["冲突归并"] + N5 --> N6["填写 Word"] + N6 --> N7["转换 PDF P1"] + N6 --> N8["追溯清单"] + N7 --> N9["输出下载"] + N8 --> N9 + N9 --> N10["飞书通知"] + N10 --> N11["完成"] +``` + +### 5.2 节点定义 + +| 节点编码 | 节点名称 | 触发服务 | 成功条件 | 失败处理 | +| --- | --- | --- | --- | --- | +| prepare | 准备资料 | `FormFillWorkflowExecutor` | 找到或生成成功的 `FileSummaryBatch` | 无文件汇总则暂停提示上传 | +| template_select | 选择模板 | `TemplateSelectionService` | 输出本次目标模板列表 | 无适用模板则失败 | +| template_copy | 复制模板 | `TemplateRepository` | 模板副本进入批次工作目录 | 单模板失败不影响其他模板 | +| field_extract | 抽取字段 | `FieldExtractionService` | 规则/正则与 LLM 结果留底 | 单文件失败记录并继续 | +| conflict_merge | 冲突归并 | `FieldMergeService` | 输出最终字段和冲突列表 | 无字段时仍生成空模板 | +| word_fill | 填写 Word | `WordTemplateFillService` | 生成填好后的 Word 文件 | 单模板失败记录失败 | +| pdf_convert | 转换 PDF | `PdfConversionService` | P1:生成 PDF 文件 | PDF 失败标记 partial_success | +| trace_export | 追溯清单 | `TraceabilityExportService` | 生成 Excel/JSON 追溯清单 | 失败不影响 Word | +| output_export | 输出下载 | `FormFillExportService` | 写入 `ExportedSummaryFile` 并生成下载链接 | 关键 Word 失败则批次失败 | +| notify | 飞书通知 | `FeishuNotifier` | 通知上传人生成完成 | 通知失败不影响下载 | +| completed | 完成 | 工作流执行器 | 更新批次状态和对话消息 | - | + +### 5.3 状态设计 + +| 状态 | 含义 | +| --- | --- | +| pending | 已创建,等待执行 | +| running | 执行中 | +| waiting_user | 缺少文件或关键条件,等待用户补充 | +| success | Word 和必要追溯产物生成成功 | +| partial_success | Word 已生成,但部分模板、PDF、追溯清单或通知失败 | +| failed | 所有目标 Word 模板均生成失败 | +| skipped | 当前节点不适用,例如 Demo 阶段跳过 PDF | + +--- + +## 六、模板配置设计 + +### 6.1 配置文件路径 + +建议新增: + +```text +review_agent/application_form_fill/templates/application_form_templates_v1.yaml +``` + +### 6.2 配置结构 + +```yaml +version: application_form_templates_v1 +source_dir: docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告 +templates: + - code: registration_certificate + name: 中华人民共和国医疗器械注册证(体外诊断试剂)(格式) + source_file: 中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx + output_label: 注册证格式 + applies_when: + registration_type: ["首次注册"] + file_format: docx + fields: + - key: product_name + label: 产品名称 + target: + type: table_row + row_label: 产品名称 + sources: ["说明书", "产品技术要求", "注册检验报告"] + - key: package_specification + label: 包装规格 + target: + type: table_row + row_label: 包装规格 + sources: ["说明书", "产品技术要求"] +``` + +### 6.3 模板配置项 + +| 配置项 | 说明 | +| --- | --- | +| code | 模板编码,用于用户指定和导出分类 | +| name | 模板中文名称 | +| source_file | 原始模板文件名 | +| working_template | 可选,预转换 `.docx` 工作模板 | +| output_label | 文件命名中的模板标签 | +| applies_when | 默认适用注册类型 | +| fields | 字段映射列表 | +| checklist_items | 安全和性能基本原则清单条目映射 | +| conversion | `.doc` 转 `.docx` 和 PDF 的转换策略 | + +### 6.4 已知模板字段 + +注册证格式当前已从 `.docx` 表格识别到以下字段:注册人名称、注册人住所、生产地址、代理人名称、代理人住所、产品名称、包装规格、主要组成成分、预期用途、产品储存条件及有效期、附件、其他内容、备注。 + +变更注册(备案)文件和安全和性能基本原则清单当前为 `.doc`,实施前需通过 LibreOffice/soffice 转换或预置人工确认版 `.docx` 工作模板,再补齐字段映射。 + +--- + +## 七、字段抽取与合并设计 + +### 7.1 三层提取链路 + +```text +模板字段配置 +-> 文档字段候选提取 +-> 规则/正则抽取与 LLM 结构化抽取并行 +-> 字段归一化 +-> 来源优先级合并 +-> 冲突识别 +-> 最终字段包 +``` + +### 7.2 规则/正则抽取 + +| 能力 | 说明 | +| --- | --- | +| 标签字段识别 | 识别 `产品名称:`、`预期用途:`、`储存条件:` 等标签行 | +| 表格字段识别 | 从 Word/Excel 表格中识别左侧字段名、右侧字段值 | +| 章节范围识别 | 从说明书、产品技术要求中按章节提取连续文本 | +| 文件类型识别 | 根据文件名、目录名和首页标题判断说明书、产品技术要求、检验报告 | +| 证据片段截取 | 保存字段前后上下文,用于追溯清单 | + +### 7.3 LLM 结构化抽取 + +LLM 输入为模板字段清单、文件上下文和候选文本片段,输出严格 JSON: + +```json +{ + "fields": [ + { + "key": "storage_condition", + "label": "产品储存条件及有效期", + "value": "2-8℃保存,有效期12个月", + "source_file": "说明书.docx", + "evidence": "产品储存条件:2-8℃保存...", + "confidence": 0.86 + } + ], + "checklist_items": [ + { + "item_code": "A1", + "applicability": "适用", + "compliance_evidence": "产品技术要求中规定了性能指标和检验方法", + "proof_location": "产品技术要求.docx 第2章" + } + ] +} +``` + +### 7.4 并行合并规则 + +| 场景 | 处理规则 | +| --- | --- | +| 规则和 LLM 值一致 | 合并为同一字段,提高置信度 | +| 规则和 LLM 值不一致,但来源文件不同 | 按来源文件优先级处理,说明书优先 | +| 规则和 LLM 值不一致,来源文件相同 | 标记冲突,模板中高亮 | +| 说明书与其他文件冲突 | 采用说明书值,黄色底色、红色字体标记 | +| 说明书缺失,多个来源冲突 | 取最高优先级文件值并标记冲突;无法判断则留空 | +| 字段缺失 | 模板留空,追溯清单记录未提取 | + +### 7.5 过程产物留底 + +字段抽取结果保存为 `field_extract_result.json`,至少包含: + +| 内容 | 说明 | +| --- | --- | +| regex_results | 规则/正则抽取结果 | +| llm_results | LLM 结构化抽取结果 | +| merged_fields | 合并后的最终字段 | +| conflicts | 冲突字段列表 | +| source_evidence | 来源文件和文本片段 | +| selected_templates | 本次选择的模板 | + +--- + +## 八、安全和性能基本原则清单设计 + +### 8.1 判断策略 + +安全和性能基本原则清单采用“候选判断 + 高置信度写入”策略。 + +| 步骤 | 说明 | +| --- | --- | +| 条目拆解 | 从模板配置中读取条目编号、原则内容、适用性栏、证据栏、证明文件位置栏 | +| 候选判断 | 规则和 LLM 均可给出适用/不适用候选 | +| 证据匹配 | 从产品技术要求、说明书、性能研究、稳定性研究、风险管理资料中匹配证明文件 | +| 高置信度写入 | 仅将高置信度判断写入 Word | +| 低置信度留空 | 证据不足或判断不一致时 Word 留空,追溯清单记录候选判断 | +| 冲突提示 | 冲突条目在对话框和追溯清单中提示,不强行填入 | + +### 8.2 输出字段 + +| 字段 | 说明 | +| --- | --- | +| 条目编号 | 基本原则清单中的条目编码 | +| 条目内容 | 原始原则或要求 | +| 适用性 | 适用/不适用,低置信度留空 | +| 符合性证据 | 高置信度证据摘要 | +| 证明文件位置 | 文件名、章节、页码或文本定位 | +| 置信度 | 用于判断是否写入 Word | +| 候选来源 | 规则、LLM 或两者一致 | + +--- + +## 九、Word 与 PDF 生成设计 + +### 9.1 Word 模板填充 + +| 能力 | 说明 | +| --- | --- | +| 模板副本 | 原始模板复制到批次工作目录后再写入 | +| 表格行填充 | 根据行首字段名定位目标单元格 | +| 段落占位填充 | 支持 `{{field_key}}` 等占位符 | +| 清单条目填充 | 按条目编号和配置列写入适用性、证据和证明位置 | +| 冲突高亮 | 冲突字段使用黄色底色和红色字体 | +| 缺失字段 | 保持空白,不写“待补充” | +| 版式保持 | 尽量不改变表格结构、分页和字体 | + +### 9.2 PDF 转换 + +PDF 转换作为 P1 待办增强项设计: + +| 阶段 | 处理 | +| --- | --- | +| Demo 主链路 | 优先生成 Word,不因 PDF 能力缺失阻断工作流 | +| P1 增强 | 使用 LibreOffice/soffice headless 将 Word 转为 PDF | +| 失败处理 | Word 已生成但 PDF 失败时,批次状态为 `partial_success` | +| QA 增强 | 后续增加 PDF 页数非 0、逐页截图或版式差异检查 | + +--- + +## 十、输出与下载设计 + +### 10.1 输出文件 + +| 文件 | Demo 阶段 | P1/P2 | +| --- | --- | --- | +| 填好后的 Word | 必须生成 | 持续支持 | +| PDF 预览 | 待办增强 | LibreOffice 转换生成 | +| 字段来源追溯清单 Excel | 允许生成,建议实现 | 增加多 Sheet | +| 字段抽取 JSON | 过程产物留底 | 支持下载或调试查看 | + +### 10.2 文件命名 + +```text +批次号-产品名称-注册证格式.docx +批次号-产品名称-注册证格式.pdf +批次号-产品名称-变更注册备案文件.docx +批次号-产品名称-变更注册备案文件.pdf +批次号-产品名称-安全和性能基本原则清单.docx +批次号-产品名称-安全和性能基本原则清单.pdf +批次号-产品名称-字段来源追溯清单.xlsx +``` + +### 10.3 ExportedSummaryFile 扩展 + +继续复用 `ExportedSummaryFile`,但需要扩展 `ExportType`: + +| export_type | 说明 | +| --- | --- | +| markdown | 既有 Markdown 报告 | +| excel | Excel 追溯清单 | +| json | 字段抽取 JSON 或结果包 | +| word | 填好的 Word 文件,新增 | +| pdf | Word 转换后的 PDF,新增 | + +填表工作流导出记录建议: + +| 字段 | 值 | +| --- | --- | +| workflow_type | `application_form_fill` | +| workflow_batch_id | `ApplicationFormFillBatch.id` | +| export_category | `filled_template`、`traceability`、`extract_result` | +| export_type | `word`、`pdf`、`excel`、`json` | + +导出服务入参应包含目标输出类型列表,例如: + +```json +{ + "output_types": ["word", "pdf", "excel"], + "template_codes": ["registration_certificate", "essential_principles"] +} +``` + +系统根据入参决定生成哪些类型的内容。 + +--- + +## 十一、数据模型设计 + +### 11.1 ApplicationFormFillBatch + +新增自动填表批次表。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| id | BigAutoField | 主键 | +| conversation | ForeignKey(Conversation) | 绑定对话 | +| user | ForeignKey(User) | 发起用户 | +| source_summary_batch | ForeignKey(FileSummaryBatch) | 文件来源批次 | +| source_regulatory_batch | ForeignKey(RegulatoryReviewBatch, null=True) | 可选,复用已确认法规条件 | +| batch_no | CharField | 填表批次号,如 AFF-YYYYMMDDHHMMSS | +| status | CharField | pending、running、waiting_user、success、partial_success、failed | +| trigger_message | ForeignKey(Message, null=True) | 触发消息 | +| requested_templates | JSONField | 用户指定模板 | +| selected_templates | JSONField | 实际生成模板 | +| output_types | JSONField | 请求输出类型,如 word、pdf、excel | +| registration_type | CharField | 注册类型 | +| product_name | CharField | 产品名称 | +| conflict_summary | JSONField | 冲突摘要 | +| risk_notes | JSONField | 不适用模板、低置信度等提示 | +| work_dir | CharField | 批次工作目录 | +| error_message | TextField | 异常说明 | +| created_at | DateTimeField | 创建时间 | +| started_at | DateTimeField | 开始时间 | +| finished_at | DateTimeField | 完成时间 | + +### 11.2 ApplicationFormFillArtifact + +可新增独立过程产物表,也可复用 `RegulatoryArtifact`。考虑到这是独立工作流,建议新增轻量产物表,结构与 `RegulatoryArtifact` 保持一致。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| id | BigAutoField | 主键 | +| batch | ForeignKey(ApplicationFormFillBatch) | 所属填表批次 | +| artifact_type | CharField | template_copy、field_extract_result、merged_fields、traceability、notification_record | +| file_format | CharField | json、excel、docx、pdf | +| name | CharField | 产物名称 | +| storage_path | CharField | 存储路径 | +| metadata | JSONField | 模板编码、输出类型、生成状态等 | +| content_hash | CharField | 文件 hash | +| created_at | DateTimeField | 创建时间 | + +### 11.3 与既有模型关系 + +```text +Conversation 1:N ApplicationFormFillBatch +FileSummaryBatch 1:N ApplicationFormFillBatch +RegulatoryReviewBatch 0:N ApplicationFormFillBatch +ApplicationFormFillBatch 1:N ApplicationFormFillArtifact +ApplicationFormFillBatch 1:N WorkflowNodeRun +ApplicationFormFillBatch 1:N ExportedSummaryFile +``` + +--- + +## 十二、后端服务设计 + +### 12.1 FormFillWorkflowExecutor + +| 方法 | 说明 | +| --- | --- | +| run(batch) | 串行执行自动填表节点 | +| run_node(node) | 执行单节点并记录进度 | +| resolve_source_summary_batch() | 根据本次附件或最近成功批次确定来源 | +| emit_event() | 写入 `WorkflowEvent` | +| complete_or_partial() | 根据 Word/PDF/通知结果更新批次状态 | + +### 12.2 TemplateSelectionService + +| 方法 | 说明 | +| --- | --- | +| parse_requested_templates(message) | 从用户话语中识别指定模板 | +| detect_registration_type() | 按用户话语、法规确认条件、文件抽取识别注册类型 | +| select_templates() | 根据注册类型和用户指令输出模板列表 | + +### 12.3 TemplateRepository + +| 方法 | 说明 | +| --- | --- | +| load_config() | 读取 YAML 模板配置 | +| resolve_source_template(code) | 找到原始模板或预转换模板 | +| copy_to_work_dir(code, batch) | 复制模板到批次目录 | +| convert_doc_to_docx(path) | `.doc` 转 `.docx` | + +### 12.4 FieldExtractionService + +| 方法 | 说明 | +| --- | --- | +| extract_by_rules(texts, template_fields) | 规则/正则抽取 | +| extract_by_llm(texts, template_fields) | LLM 结构化抽取 | +| run_parallel() | 并行执行两路抽取 | +| save_extract_artifact() | 保存 `field_extract_result.json` | + +### 12.5 FieldMergeService + +| 方法 | 说明 | +| --- | --- | +| normalize_fields() | 字段名、单位、空白和同义词归一 | +| rank_sources() | 按说明书、产品技术要求、检验报告等来源排序 | +| merge() | 输出最终字段 | +| detect_conflicts() | 输出冲突列表和高亮标记 | + +### 12.6 WordTemplateFillService + +| 方法 | 说明 | +| --- | --- | +| fill_table_rows() | 根据行名定位表格单元格并写入 | +| fill_placeholders() | 替换段落占位符 | +| fill_checklist_items() | 写入安全和性能基本原则清单 | +| apply_conflict_highlight() | 黄底红字标记冲突字段 | +| save_docx() | 保存填好后的 Word | + +### 12.7 TraceabilityExportService + +| 方法 | 说明 | +| --- | --- | +| build_excel() | 生成字段来源追溯清单 | +| build_json() | 生成结构化追溯 JSON | +| create_export_records() | 写入 `ExportedSummaryFile` | + +### 12.8 FormFillNotifier + +复用或包装 `FeishuNotifier`。 + +| 通知场景 | 说明 | +| --- | --- | +| 填表成功 | 通知上传人文件已生成 | +| 部分成功 | 通知 Word 已生成,但 PDF/部分模板失败 | +| 冲突字段存在 | 通知中提示存在冲突字段,需下载核对 | +| 失败 | 可选通知失败原因,Demo 可只在对话框展示 | + +--- + +## 十三、接口设计 + +### 13.1 发起自动填表 + +| 项目 | 内容 | +| --- | --- | +| URL | POST /api/review-agent/application-form-fill/start/ | +| 认证 | 登录用户 | +| 请求 | conversation_id、message_id、file_summary_batch_id 可选、template_codes 可选、output_types 可选 | +| 响应 | batch_id、workflow_type、status、selected_templates | + +处理规则: + +```text +校验 conversation 属于当前用户 +-> 如本次消息带附件,先执行文件汇总 +-> 否则查找当前对话最近成功 FileSummaryBatch +-> 创建 ApplicationFormFillBatch +-> 初始化 WorkflowNodeRun +-> 启动 FormFillWorkflowExecutor +-> 返回工作流卡片初始状态 +``` + +### 13.2 查询自动填表状态 + +| 项目 | 内容 | +| --- | --- | +| URL | GET /api/review-agent/application-form-fill/{batch_id}/ | +| 认证 | 登录用户 | +| 响应 | 批次状态、节点状态、选择模板、冲突摘要、导出文件 | + +### 13.3 下载导出文件 + +继续复用: + +| 项目 | 内容 | +| --- | --- | +| URL | GET /api/review-agent/file-summary/exports/{export_id}/download/ | +| 认证 | 登录用户 | +| 响应 | 文件流 | + +权限规则: + +```text +export_id -> workflow_type/workflow_batch_id -> ApplicationFormFillBatch -> conversation -> user +必须等于当前登录用户,才允许下载。 +``` + +--- + +## 十四、前端设计 + +### 14.1 自动填表工作流卡片 + +前端新增独立卡片类型 `application_form_fill`,展示节点: + +| 节点 | 展示文案 | +| --- | --- | +| prepare | 准备资料 | +| template_select | 选择模板 | +| template_copy | 复制模板 | +| field_extract | 抽取字段 | +| conflict_merge | 冲突归并 | +| word_fill | 填写 Word | +| pdf_convert | 转换 PDF | +| output_export | 输出下载 | +| notify | 飞书通知 | +| completed | 已完成 | + +### 14.2 对话框结果展示 + +工作流完成后,AI 对话框展示 Markdown 摘要: + +```markdown +已生成申报模板自动填表文件。 + +| 文件 | Word | PDF | +| --- | --- | --- | +| 注册证格式 | 下载 | 待生成 | +| 安全和性能基本原则清单 | 下载 | 待生成 | + +| 冲突字段 | 采用值 | 冲突来源 | 处理 | +| --- | --- | --- | --- | +| 储存条件 | 2-8℃保存 | 产品技术要求:-20℃保存 | 已按说明书填入,并在模板中高亮 | + +[下载字段来源追溯清单](download-url) +``` + +### 14.3 指定模板交互 + +用户可以通过自然语言指定模板。前端无需额外表单,后端意图识别后在卡片中展示本次选择模板。 + +--- + +## 十五、事件设计 + +### 15.1 SSE 事件结构 + +```json +{ + "event": "workflow", + "workflow_type": "application_form_fill", + "batch_id": 3001, + "conversation_id": 1001, + "node_code": "field_extract", + "node_group": "form_fill", + "status": "running", + "progress": 55, + "message": "正在并行抽取模板字段", + "payload": { + "selected_templates": ["registration_certificate", "essential_principles"], + "processed_files": 8, + "total_files": 20 + } +} +``` + +### 15.2 节点进度 + +| 节点 | 进度口径 | +| --- | --- | +| 准备资料 | 是否找到来源批次 | +| 选择模板 | 模板数量 | +| 复制模板 | 已复制模板数/总模板数 | +| 抽取字段 | 已处理文件数/总文件数 | +| 冲突归并 | 字段数量和冲突数量 | +| 填写 Word | 已生成 Word 数/目标 Word 数 | +| 转换 PDF | 已生成 PDF 数/目标 PDF 数 | +| 输出下载 | 已创建下载记录数 | +| 飞书通知 | 通知状态 | + +--- + +## 十六、异常与降级设计 + +| 场景 | 处理 | +| --- | --- | +| 无成功文件汇总批次 | 进入 waiting_user,提示上传资料 | +| 新附件汇总失败 | 填表工作流不启动或标记失败 | +| 用户指定不适用模板 | 允许生成,摘要提示需人工确认 | +| `.doc` 转换失败 | 该模板失败,其他模板继续 | +| 单字段缺失 | Word 留空,追溯清单记录未提取 | +| 规则和 LLM 冲突 | 按来源优先级合并,冲突高亮 | +| 所有 Word 生成失败 | 批次 failed | +| 部分 Word 生成失败 | 批次 partial_success | +| PDF 转换失败 | 批次 partial_success,保留 Word 下载 | +| 飞书通知失败 | 不影响文件下载,记录通知失败 | + +--- + +## 十七、安全设计 + +| 设计点 | 说明 | +| --- | --- | +| 原始模板保护 | 只读原始模板,所有写入发生在批次工作目录副本 | +| 对话隔离 | 填表批次必须绑定当前 Conversation | +| 文件读取权限 | 只能读取关联 `FileSummaryBatch` 下的文件 | +| 下载权限 | 根据 workflow_type 和 workflow_batch_id 校验当前用户 | +| LLM 输入控制 | 只传必要文本片段和字段上下文,避免发送整包敏感资料 | +| 飞书脱敏 | 通知仅包含生成状态、模板名称、冲突数量和系统内下载提示 | +| 命令调用安全 | LibreOffice/飞书 CLI 使用结构化参数,不拼接用户输入 | + +--- + +## 十八、验收设计 + +| 序号 | 验收项 | 验收标准 | +| --- | --- | --- | +| 1 | 意图触发 | 用户说“帮我填注册证”等语句可触发 `application_form_fill` | +| 2 | 指定模板 | 用户指定模板时只生成指定模板 | +| 3 | 默认模板 | 未指定模板时按注册类型生成适用的全部模板 | +| 4 | 新附件串联 | 本次消息带附件时先自动汇总,再执行填表 | +| 5 | 最近批次复用 | 无附件时复用当前对话最近成功文件汇总批次 | +| 6 | 工作流卡片 | 前端展示准备资料、选择模板、复制模板、抽取字段、填写 Word 等节点 | +| 7 | 字段并行抽取 | 规则/正则和 LLM 抽取结果均进入过程产物 | +| 8 | 冲突归并 | 说明书优先,冲突字段在 Word 中黄底红字 | +| 9 | 缺失字段 | 未提取字段在 Word 中留空 | +| 10 | 基本原则清单 | 高置信度条目写入,低置信度候选留在追溯清单 | +| 11 | Word 下载 | 对话框提供填好后的 Word 下载链接 | +| 12 | PDF 待办 | Demo 阶段 PDF 可展示为待生成,不阻断 Word | +| 13 | 追溯清单 | 生成字段来源追溯清单,包含规则、LLM、合并和冲突信息 | +| 14 | 飞书通知 | 填表完成后可通知上传人,失败不影响下载 | +| 15 | 权限隔离 | A 对话生成的 Word/追溯清单不能被 B 对话访问 | + +--- + +## 十九、实施建议 + +1. 新增 `ApplicationFormFillBatch` 和 `ApplicationFormFillArtifact` 数据模型,扩展 `ExportedSummaryFile.ExportType` 支持 `word`、`pdf`。 +2. 新增模板配置 `application_form_templates_v1.yaml`,先录入注册证格式 `.docx` 的已识别字段。 +3. 将两个 `.doc` 模板转换为 `.docx` 工作模板,或在配置中标记为待转换模板。 +4. 实现 `TemplateSelectionService`,支持用户指定模板、注册类型识别和默认模板选择。 +5. 实现规则/正则与 LLM 并行字段抽取,并保存 `field_extract_result.json`。 +6. 实现 `FieldMergeService`,按说明书优先规则处理冲突。 +7. 实现 `WordTemplateFillService`,优先支持表格行填充和冲突高亮。 +8. 实现追溯清单 Excel 导出和 Word 下载记录。 +9. 改造前端工作流卡片,新增 `application_form_fill` 类型。 +10. 接入飞书通知摘要。 +11. 将 PDF 转换、逐页版式 QA 和更完整的 `.doc` 模板转换纳入后续待办。 + +--- + +## 二十、待办与待确认事项 + +| 序号 | 项目 | 当前建议 | +| --- | --- | --- | +| 1 | PDF 转换 | 放入待办,Demo 优先 Word 下载 | +| 2 | `.doc` 模板转换 | 优先 LibreOffice/soffice;不可用时预置 `.docx` 工作模板 | +| 3 | 安全和性能基本原则清单条目拆解 | 需转换模板后补齐 YAML 条目配置 | +| 4 | LLM 结构化抽取提示词 | 需约束输出 JSON schema 和置信度 | +| 5 | 飞书通知渠道 | Demo 可 mock 或 CLI,正式版接 Webhook/API | +| 6 | 低置信度阈值 | 建议功能实现阶段先配置为 0.75 | +| 7 | 版式验证 | P1 增加 PDF 页数检查和逐页截图 QA | diff --git a/docs/2.功能设计/4.飞书通知与问答接入.md b/docs/2.功能设计/4.飞书通知与问答接入.md new file mode 100644 index 0000000..d6b78d3 --- /dev/null +++ b/docs/2.功能设计/4.飞书通知与问答接入.md @@ -0,0 +1,292 @@ +# 飞书通知与问答接入功能设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/4.飞书通知与问答接入.md | +| 依赖功能设计 | docs/2.功能设计/1.自动汇总.md;docs/2.功能设计/2.NMPA注册资料法规核查与整改闭环.md;docs/2.功能设计/3.产品关键信息提取与申报文件自动填表.md | +| 功能名称 | 飞书通知与问答接入 | +| 所属模块 | 审核智能体 review_agent | +| 设计日期 | 2026-06-07 | +| 设计版本 | V1.0 | + +--- + +## 一、设计目标 + +本功能用于将系统内工作流结果通过飞书官方智能体/应用机器人同步到指定个人账号,并为后续飞书内问答能力预留数据模型和服务边界。首期实现重点是:自动汇总、NMPA 注册资料法规核查与整改闭环、产品关键信息提取与申报文件自动填表三个流程结束后,使用 App ID/App Secret 获取 `tenant_access_token`,调用飞书消息 API 向指定个人账号发送富文本私聊提醒。 + +首期不实现飞书事件订阅回调和私聊问答,但需要在设计上预留用户映射、查询服务、权限过滤和问答日志能力,保证后续可以平滑扩展到“用户在飞书私聊机器人中查询批次状态、风险摘要、缺失摘要和导出摘要”。 + +--- + +## 二、设计范围 + +### 2.1 本期范围 + +| 序号 | 范围项 | 说明 | +| --- | --- | --- | +| 1 | 指定个人通知 | 通过飞书官方智能体/应用机器人消息 API 向一个指定个人账号发送通知 | +| 2 | 发起人展示 | 消息正文展示批次发起人或上传人,不做群内 @ | +| 3 | 三流程接入 | 自动汇总、法规核查、自动填表均发送完成通知 | +| 4 | 富文本消息 | 使用飞书富文本格式展示标题、批次、状态、摘要、链接和发起人 | +| 5 | token 管理 | 使用 App ID/App Secret 获取并缓存 tenant_access_token | +| 6 | 通知判重 | 同一批次、同一工作流、同一状态只发送一次 | +| 7 | 通知记录 | 保存摘要、通道、目标、状态、失败原因、发送时间等信息 | +| 8 | 批次详情展示 | 在对应批次详情页展示通知状态和失败原因 | +| 9 | 用户映射管理 | 通过 Django Admin 手工维护系统用户与飞书用户标识,服务后续按发起人私聊和问答身份识别 | +| 10 | 问答预留 | 预留飞书用户映射、查询服务、权限规则和问答日志模型 | + +### 2.2 非本期范围 + +| 序号 | 范围项 | 说明 | +| --- | --- | --- | +| 1 | 飞书私聊问答回调 | 不实现事件订阅接口和问答回复处理 | +| 2 | 群聊 @ 机器人问答 | 不接收群消息,不处理群内权限问题 | +| 3 | 飞书事件订阅回调 | 首期不接收私聊或群聊消息事件 | +| 4 | 复杂消息卡片 | 不做交互式卡片按钮和回调 | +| 5 | 自动后台重试 | 飞书发送失败只记录,不自动重试 | +| 6 | 飞书通讯录同步 | 不自动拉取用户,首期手工维护映射 | + +--- + +## 三、与既有功能的关系 + +| 既有能力 | 处理方式 | 说明 | +| --- | --- | --- | +| 自动汇总工作流 | 接入通知 | 文件汇总完成后生成摘要通知 | +| 法规核查工作流 | 替换/扩展 mock 通知 | 风险分级和报告生成后发送摘要通知 | +| 自动填表工作流 | 扩展现有 notifier | Word/追溯清单生成后发送摘要通知 | +| 通知记录模型 | 统一扩展 | 现有法规和填表通知记录已存在,本设计建议抽象统一通知服务 | +| 工作流事件 | 复用 | 通知发送结果可作为节点事件或批次附属信息展示 | +| Django Admin | 扩展 | 新增飞书用户映射管理入口 | + +--- + +## 四、总体架构 + +### 4.1 逻辑架构 + +```mermaid +flowchart TD + A["业务工作流完成"] --> B["NotificationDispatcher"] + B --> C["WorkflowNotificationBuilder"] + C --> D["ConfiguredPersonalRecipientResolver"] + D --> E["RichTextMessageBuilder"] + E --> F{"通知是否已发送"} + F -->|"已发送"| G["写入/返回重复跳过结果"] + F -->|"未发送"| H{"飞书通知是否启用"} + H -->|"否"| I["写入 mock/未启用记录"] + H -->|"是"| J["FeishuTokenProvider"] + J --> K["获取/复用 tenant_access_token"] + K --> L["FeishuMessageApiClient"] + L --> X["POST /im/v1/messages"] + X --> M["保存通知记录"] + M --> N["批次详情页展示"] + + O["后续飞书私聊消息"] -.预留.-> P["FeishuQuestionService"] + P -.预留.-> Q["BatchSummaryQueryService"] + Q -.预留.-> R["权限过滤"] + P -.预留.-> S["FeishuQuestionLog"] +``` + +### 4.2 模块划分 + +| 模块 | 责任 | +| --- | --- | +| `notification_dispatcher` | 工作流完成后统一调度通知发送 | +| `workflow_notification_builder` | 将不同工作流批次转换为统一通知上下文 | +| `feishu_recipient_resolver` | 首期读取配置中的个人 open_id/user_id;后续支持按系统用户映射解析 | +| `feishu_message_builder` | 构造飞书富文本消息体 | +| `feishu_token_provider` | 使用 App ID/App Secret 获取并缓存 tenant_access_token | +| `feishu_message_api_client` | 调用飞书发送消息 API、处理超时和响应解析 | +| `notification_record_service` | 判重、保存成功/失败/未启用记录 | +| `batch_notification_presenter` | 为批次详情页输出通知状态 | +| `feishu_question_service` | 后续问答预留,解析问题并查询摘要 | +| `batch_summary_query_service` | 后续问答预留,按权限查询批次摘要 | + +--- + +## 五、通知业务流程 + +### 5.1 主流程 + +```text +业务工作流进入 success、partial_success 或 failed +-> 工作流调用统一通知服务 +-> 通知服务生成 workflow_type、batch_id、status 组成的判重键 +-> 检查是否已有同一判重键的成功通知 +-> 若已有成功通知,跳过发送并返回 skipped +-> 读取批次、用户、摘要、结果链接 +-> 读取配置中的个人 open_id/user_id 作为接收人 +-> 构造富文本消息,正文展示批次发起人或上传人 +-> 判断 FEISHU_NOTIFY_ENABLED +-> 未启用时写入 mock/disabled 记录 +-> 已启用时获取或复用 tenant_access_token +-> 调用飞书消息 API 向指定个人 open_id/user_id 发送消息 +-> 发送成功写入 sent/success 记录 +-> 发送失败写入 failed 记录,记录错误信息 +-> 业务工作流不因通知失败而失败 +``` + +### 5.2 三类工作流通知摘要 + +| 工作流 | workflow_type | 摘要字段 | 下一步 | +| --- | --- | --- | --- | +| 自动汇总 | `file_summary` | 文件总数、成功解析数、失败/跳过数、导出文件数 | 查看汇总结果或下载 Excel | +| 法规核查 | `regulatory_review` | 风险总数、阻断项数、高风险数、中风险数、报告导出状态 | 查看风险报告和整改建议 | +| 自动填表 | `application_form_fill` | 选中模板数、导出文件数、冲突字段数、失败原因概述 | 下载 Word/追溯清单并人工确认 | + +### 5.3 通知状态 + +| 状态 | 含义 | 是否阻断主流程 | +| --- | --- | --- | +| pending | 已创建记录但未发送 | 否 | +| sent/success | 已成功发送到飞书 | 否 | +| failed | 发送失败或配置异常 | 否 | +| skipped_duplicate | 已存在同一批次、同一流程、同一状态通知 | 否 | +| disabled/mock | 真实通知未启用,记录为模拟或未启用 | 否 | + +--- + +## 六、飞书富文本设计 + +### 6.1 消息结构 + +飞书富文本消息建议使用 `post` 类型。首期内容只放摘要,不展开完整风险项和缺失项。 + +```json +{ + "msg_type": "post", + "content": { + "post": { + "zh_cn": { + "title": "自动填表流程已完成", + "content": [ + [ + {"tag": "text", "text": "状态:成功\n"}, + {"tag": "text", "text": "批次:AFF-20260607-001\n"}, + {"tag": "text", "text": "发起人:owner\n"} + ], + [ + {"tag": "text", "text": "摘要:生成 2 个文件,冲突字段 1 个。\n"}, + {"tag": "a", "text": "查看系统结果", "href": "http://127.0.0.1:8000/..."} + ] + ] + } + } + } +} +``` + +### 6.2 接收人标识优先级 + +首期接收人来自环境变量配置。若同时配置多个飞书标识,按以下优先级选取: + +```text +FEISHU_DEFAULT_USER_OPEN_ID -> FEISHU_DEFAULT_USER_ID +``` + +若无可用接收人标识,系统不发送真实飞书消息,并记录配置缺失失败。 + +用户映射表仍保留,用于后续从“固定个人账号”升级为“按发起人私聊”。 + +### 6.3 系统链接 + +首期使用本地地址,例如: + +```text +http://127.0.0.1:8000/ +``` + +批次详情链接由各工作流已有页面路由或详情接口拼接。部署环境后续再升级为可信域名配置。 + +--- + +## 七、配置设计 + +| 配置项 | 来源 | 是否敏感 | 说明 | +| --- | --- | --- | --- | +| FEISHU_NOTIFY_ENABLED | 环境变量 | 否 | 是否启用真实飞书通知 | +| FEISHU_NOTIFY_CHANNEL | 环境变量 | 否 | 首期为 `feishu_api` | +| FEISHU_APP_ID | 环境变量 | 是 | 飞书智能体/企业自建应用 App ID | +| FEISHU_APP_SECRET | 环境变量 | 是 | 飞书智能体/企业自建应用 App Secret | +| FEISHU_DEFAULT_USER_OPEN_ID | 环境变量 | 否 | 首期指定接收人的飞书 open_id | +| FEISHU_DEFAULT_USER_ID | 环境变量 | 否 | 首期指定接收人的飞书 user_id | +| FEISHU_DEFAULT_TARGET_NAME | 环境变量 | 否 | 固定群展示名称 | +| FEISHU_TENANT_TOKEN_CACHE_SECONDS | 环境变量 | 否 | tenant_access_token 本地缓存秒数 | +| FEISHU_REQUEST_TIMEOUT_SECONDS | 环境变量 | 否 | 默认 5 秒 | +| 系统用户与飞书用户映射 | Django Admin | 部分敏感 | open_id、user_id、mobile | + +--- + +## 八、页面设计 + +### 8.1 Django Admin + +新增飞书用户映射管理: + +| 字段 | 列表展示 | 可搜索 | 可过滤 | +| --- | --- | --- | --- | +| system_user | 是 | username | 是 | +| feishu_display_name | 是 | 是 | 否 | +| feishu_open_id | 否 | 是 | 否 | +| feishu_user_id | 否 | 是 | 否 | +| feishu_mobile | 否 | 是 | 否 | +| is_active | 是 | 否 | 是 | + +### 8.2 批次详情页 + +三个流程对应的批次详情或结果区域展示通知状态: + +| 展示项 | 说明 | +| --- | --- | +| 通知通道 | mock、feishu_api | +| 通知目标 | 指定个人账号名称或配置名称 | +| 接收人 | 指定个人账号;后续可展示发起人/上传人的飞书展示名 | +| 发送状态 | 成功、失败、未启用、重复跳过 | +| 发送时间 | 成功发送时间 | +| 失败原因 | 配置错误、超时、飞书返回错误等摘要 | + +--- + +## 九、飞书问答预留设计 + +### 9.1 首期预留能力 + +| 能力 | 设计说明 | +| --- | --- | +| 用户映射复用 | 后续私聊事件中的飞书用户 ID 可通过映射表关联系统用户 | +| 批次查询服务 | 预留按批次号、工作流类型、最近批次查询摘要的服务 | +| 权限过滤 | 普通用户只查自己发起或上传的批次;管理员可查全部 | +| 问答日志 | 预留日志表或服务接口,记录问题、意图、查询对象和回答摘要 | + +### 9.2 后续问答能力边界 + +| 问题类型 | 首期问答 MVP 是否支持 | +| --- | --- | +| 查最近批次状态 | 是 | +| 查指定批次状态 | 是 | +| 查风险摘要 | 是 | +| 查缺失摘要 | 是 | +| 查导出摘要 | 是 | +| 解释具体整改建议 | 否 | +| 重新发起工作流 | 否 | + +--- + +## 十、验收标准 + +| 序号 | 验收项 | 标准 | +| --- | --- | --- | +| 1 | 三流程通知 | 自动汇总、法规核查、自动填表完成后均调用统一通知服务 | +| 2 | 个人账号发送 | 配置 App ID、App Secret 和指定个人 open_id/user_id 后,个人飞书账号能收到富文本通知 | +| 3 | 发起人展示 | 消息正文能展示流程发起人或上传人 | +| 4 | 接收人缺失 | 指定接收人缺失时不发送真实消息,并记录配置错误 | +| 5 | token 管理 | 系统能获取并缓存 tenant_access_token,token 失效后可重新获取 | +| 6 | 判重 | 同一批次、同一流程、同一状态不会重复发送成功通知 | +| 7 | 失败不阻断 | 飞书接口失败时主工作流仍完成 | +| 8 | 记录落库 | 成功、失败、未启用、重复跳过均可追溯 | +| 9 | 页面展示 | 批次详情页展示通知状态和失败原因 | +| 10 | 问答预留 | 用户映射、查询服务边界和日志设计可支持后续私聊问答 | diff --git a/docs/2.功能设计/5.第1章监管信息材料包生成.md b/docs/2.功能设计/5.第1章监管信息材料包生成.md new file mode 100644 index 0000000..11d158d --- /dev/null +++ b/docs/2.功能设计/5.第1章监管信息材料包生成.md @@ -0,0 +1,873 @@ +# 第1章监管信息材料包生成功能设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/5.第1章监管信息材料包生成.md | +| 参考功能设计 | docs/2.功能设计/3.产品关键信息提取与申报文件自动填表.md | +| 功能名称 | 第1章监管信息材料包生成 | +| 工作流编码 | regulatory_info_package | +| 所属模块 | 审核智能体 review_agent | +| 设计日期 | 2026-06-10 | +| 设计版本 | V1.0 | + +--- + +## 一、设计目标 + +新增独立工作流 `regulatory_info_package`,用于根据产品说明书生成第1章监管信息材料包。用户在对话中上传或选择一个产品说明书,发送“根据说明书生成第1章监管信息”等指令后,系统复制 `docs/0.原始材料/第1章 监管信息` 下的 7 个样例模板,抽取说明书中的产品关键信息,生成一套新的第1章监管信息文件,并打包为 `第1章 监管信息(预生成版).zip` 作为主下载入口。 + +本功能与 `application_form_fill` 平级,不复用其 workflow_type 和批次表;但复用其已形成的服务思想和部分可拆能力,包括字段抽取、LLM 调用、Word 写入、追溯清单、导出下载、通知、工作流事件和前端卡片。 + +本期重点实现: + +| 目标 | 说明 | +| --- | --- | +| 独立工作流 | 新增 `regulatory_info_package` 批次、节点和卡片 | +| 单说明书输入 | 直接从当前对话 active 附件中选择唯一说明书;兼容最近成功文件汇总批次 | +| 模板驱动 | 通过 YAML 配置维护 7 个模板、字段映射和生成策略 | +| 模板字段化 | 优先使用 Word 内容控件 Tag 或稳定占位符,让代码只写字段值,最大限度保留原格式 | +| 规则 + LLM 并行抽取 | 代码抽取与 LLM 抽取并行,合并后写入模板 | +| 待确认高亮 | 系统新填入的 `/`、LLM-only 字段、冲突字段均高亮 | +| `.doc` 等价处理 | 设计 `LegacyWordDocumentService`,按能力驱动提供与 `.docx` 一致的文档操作接口;原生能力不可用时明确兜底 | +| zip 主输出 | 扩展 `ExportedSummaryFile.ExportType.ZIP`,统一下载权限 | +| LLM 意图路由 | 扩展路由 action,支持固定话术和 LLM 语义判断 | + +--- + +## 二、规范依据与裁决 + +| 规范来源 | 命中内容 | 设计处理 | +| --- | --- | --- | +| GYRX 后端开发规范 | 服务层职责清晰、接口响应统一、记录必要日志 | Django 项目沿用现有 JsonResponse/SSE 模式;服务拆入独立模块,记录批次与节点日志 | +| GYRX 前端开发规范 | 前端样式复用、交互一致、下载图标语义 | 当前项目为 Django 模板 + 原生 JS,按现有工具 chip、工作流卡片和下载链接风格扩展 | +| 既有自动填表设计 | 独立工作流、YAML 配置、字段抽取、追溯清单、导出记录 | 复用模式,不复用批次表和 workflow_type | +| 需求分析确认 | `.doc` 不只依赖转换、zip 主入口、LLM-only 高亮 | 在服务抽象和验收标准中作为强约束 | + +冲突裁决:GYRX 规范中部分 Java/Spring 约束不适用于当前 Django 项目,按当前项目既有 Django 架构落地;通用原则如服务拆分、日志、权限和前端交互一致性继续采用。 + +--- + +## 三、与既有功能关系 + +### 3.1 复用边界 + +| 能力 | 处理方式 | 现有代码/模块 | +| --- | --- | --- | +| 对话与消息 | 复用 | `Conversation`、`Message`、`stream_message` | +| 附件上传 | 复用 | `FileAttachment`、`file_summary.storage` | +| 文件汇总结果 | 兼容复用 | `FileSummaryBatch`、`FileSummaryItem` | +| 文本抽取 | 复用并扩展 | `regulatory_review/services/text_extract.py`、`rag_index.py` | +| LLM 调用 | 复用 | `review_agent/llm.py` | +| 知识库搜索 | 复用系统现有能力 | `knowledge_base.py`、法规 RAG 相关服务 | +| 导出下载 | 扩展复用 | `ExportedSummaryFile`、`file_summary.views.export_download` | +| 工作流事件 | 复用 | `WorkflowNodeRun`、`WorkflowEvent` | +| 通知 | 复用统一通知链路 | `review_agent.notifications` | +| 前端卡片 | 扩展复用 | `templates/home.html`、`static/js/app.js` | + +### 3.2 新增边界 + +| 能力 | 说明 | +| --- | --- | +| 独立批次 | 新增 `RegulatoryInfoPackageBatch`,批次号 `RIP-...` | +| 独立产物 | 新增 `RegulatoryInfoPackageArtifact` 记录模板副本、抽取结果、生成文件、zip 和追溯清单 | +| 独立通知记录 | 新增 `RegulatoryInfoPackageNotificationRecord`,结构与自动填表通知保持一致 | +| 模板配置 | 新增 `regulatory_info_package_templates_v1.yaml` | +| 说明书选择 | 新增输入选择服务,优先从 active 附件选择,兼容文件汇总批次 | +| 材料包生成 | 新增 7 个文件的生成策略和 zip 打包服务 | +| `.doc` 适配 | 新增旧版 Word 文档适配层 | + +--- + +## 四、总体架构 + +### 4.1 目录结构 + +新增模块: + +```text +review_agent/ + regulatory_info_package/ + __init__.py + constants.py + schemas.py + storage.py + events.py + workflow.py + views.py + services/ + __init__.py + input_select.py + template_config.py + template_repository.py + instruction_extract.py + field_extract.py + field_merge.py + standard_candidates.py + document_writer.py + docx_document.py + legacy_doc_document.py + package_generate.py + traceability_export.py + zip_export.py + summary.py + notifier.py + templates/ + regulatory_info_package_templates_v1.yaml + prompts/ + field_extract.md + router_intent.md + standard_candidate.md +``` + +### 4.2 逻辑架构 + +```mermaid +flowchart TD + A["AI 对话页"] --> B["意图路由"] + B --> C{"action = regulatory_info_package"} + C --> D["RegulatoryInfoPackageBatch"] + D --> E["RegulatoryInfoPackageWorkflowExecutor"] + E --> F["输入说明书选择"] + E --> G["模板配置 YAML"] + F --> H["说明书文本与表格抽取"] + H --> I1["规则/代码抽取"] + H --> I2["LLM 结构化抽取"] + I1 --> J["字段合并与高亮决策"] + I2 --> J + J --> K["标准候选服务"] + J --> L["材料包生成服务"] + K --> L + L --> M1["DOCX 文档适配器"] + L --> M2["Legacy DOC 文档适配器"] + M1 --> N["7 个目标文件"] + M2 --> N + N --> O["追溯清单"] + N --> P["ZIP 打包"] + O --> Q["ExportedSummaryFile"] + P --> Q + E --> R["WorkflowEvent/SSE"] + E --> S["通知服务"] +``` + +### 4.3 技术选型 + +| 设计项 | 本期方案 | 说明 | +| --- | --- | --- | +| Web 框架 | Django | 沿用当前项目 | +| 工作流执行 | 轻量 Executor + 后台线程 | 与文件汇总、法规核查、自动填表一致 | +| 工作流状态 | `WorkflowNodeRun`、`WorkflowEvent` | 使用 `workflow_type=regulatory_info_package` | +| 模板配置 | YAML | 便于维护 7 个模板和字段映射 | +| `.docx` 操作 | `python-docx` | 表格、段落、run、底色和字体可控 | +| `.doc` 操作 | 适配器抽象 | Python 标准库不支持 `.doc` 二进制 Word 写入;设计为 COM/UNO/第三方库适配器,能力不可用时使用可追溯的 `.docx` 兜底 | +| zip 打包 | Python `zipfile` 标准库 | 标准库可满足打包需求 | +| Excel 追溯 | `openpyxl` | 复用现有依赖 | +| LLM | `review_agent.llm.generate_completion` | 统一模型调用 | +| 知识库 | 系统现有知识库/RAG | 不新增单独 RAG 模块 | + +关于 `.doc`:Python 自带库不能实现类似 Apache POI HWPF 的 Word 97-2003 二进制文档完整读写。项目依赖中有 `olefile`,可读取 OLE 复合文档结构,但不足以可靠修改 Word 文本、表格和样式。因此设计上必须使用文档适配器屏蔽实现差异,底层可选 Word COM、LibreOffice UNO、专用第三方库或受控转换兜底。 + +--- + +## 五、触发与路由设计 + +### 5.1 action 扩展 + +`skill_router.py` 扩展: + +| 项 | 设计 | +| --- | --- | +| 新 action | `regulatory_info_package` | +| 新属性 | `starts_regulatory_info_package` | +| ROUTE_ACTIONS | 增加 `regulatory_info_package` | +| LLM prompt | 描述该 action 用于“根据说明书生成第1章监管信息、监管信息材料包、申请表/产品列表/声明材料包” | + +### 5.2 固定规则 + +规则预判关键词: + +```python +REGULATORY_INFO_PACKAGE_TRIGGER_KEYWORDS = [ + "根据说明书生成第1章监管信息", + "生成监管信息材料包", + "从说明书生成第1章材料", + "第1章监管信息", + "监管信息材料包", +] +``` + +规则命中时直接进入本工作流。规则未命中时,继续走 LLM 路由判断,避免自然表达漏触发。 + +### 5.3 对话启动 + +`review_agent/services.py::stream_message` 增加分支: + +```text +if route.starts_regulatory_info_package: + -> 选择说明书输入 + -> 创建 RegulatoryInfoPackageBatch + -> start_regulatory_info_package_workflow + -> SSE workflow_started + -> 回复“已启动第1章监管信息材料包生成工作流,批次号:RIP-...” +``` + +如果没有 active 附件,也没有可复用的最近文件汇总批次,则回复“请先上传产品说明书”。 +如果存在多个候选说明书且用户消息无法唯一命中文件名,则不展示选择弹窗,由对话反问用户确认具体文件名后再启动工作流。 + +--- + +## 六、输入选择设计 + +### 6.1 选择优先级 + +| 优先级 | 来源 | 规则 | +| --- | --- | --- | +| 1 | 用户消息指定文件名 | 按 active 附件名或可复用文件名模糊匹配,唯一命中则使用 | +| 2 | 当前对话 active 附件 | 文件名包含“说明书”且扩展名为 `.docx` | +| 3 | 当前对话 active 附件 | 唯一 `.docx` 文件 | +| 4 | 最近成功 `FileSummaryBatch.items` | 文件名包含“说明书”且扩展名为 `.docx` | +| 5 | 无法唯一选择 | 对话反问用户确认使用哪个说明书;必要时批次进入 `waiting_user` | + +本期直接输入只支持 `.docx` 产品说明书。`.doc`、PDF、扫描件说明书作为后续扩展;但输出模板中的 `.doc` 必须支持。 + +### 6.2 输入绑定 + +批次记录: + +| 字段 | 来源 | +| --- | --- | +| source_attachment | 直接选择的 FileAttachment | +| source_summary_batch | 可选,来自最近成功文件汇总 | +| source_summary_item | 可选,来自汇总条目 | +| source_file_name | 原始说明书文件名 | +| source_storage_path | 说明书存储路径 | + +--- + +## 七、模板配置设计 + +配置路径: + +```text +review_agent/regulatory_info_package/templates/regulatory_info_package_templates_v1.yaml +``` + +配置结构: + +```yaml +version: regulatory_info_package_templates_v1 +source_dir: docs/0.原始材料/第1章 监管信息 +output_zip_name: 第1章 监管信息(预生成版).zip +templates: + - code: ch1_2_directory + output_name: CH1.2 监管信息目录.docx + source_file: CH1.2 监管信息目录.docx + file_format: docx + strategy: directory + include_in_zip: true + fields: + - key: product_name + targets: + - type: paragraph_contains_replace + match: 呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法) + - code: ch1_4_application_form + output_name: CH1.4 申请表.docx + source_file: CH1.4 申请表.docx + file_format: docx + strategy: application_form + include_in_zip: true + - code: ch1_9_pre_submission + output_name: CH1.9 产品申报前沟通的说明.doc + source_file: CH1.9 产品申报前沟通的说明.doc + file_format: doc + strategy: pre_submission + prefer_legacy_doc_native: true + allow_docx_fallback: true + include_in_zip: true +``` + +字段映射优先级: + +| 目标类型 | 说明 | +| --- | --- | +| content_control_tag | 正式模板优先,代码按 Word 内容控件 Tag 写入 | +| placeholder | 过渡方案,替换稳定占位符并保留原 run/段落格式 | +| table_row_label | 未字段化模板的兜底方案,必须保留原单元格格式 | + +### 7.1 配置项说明 + +| 配置项 | 说明 | +| --- | --- | +| version | 配置版本,写入批次 | +| source_dir | 样例模板目录 | +| output_zip_name | zip 主输出文件名 | +| templates | 7 个目标模板 | +| code | 模板编码 | +| output_name | 生成文件名 | +| source_file | 样例文件 | +| file_format | docx/doc | +| strategy | 生成策略 | +| include_in_zip | 是否进入 zip | +| fields | 字段映射与替换目标 | +| prefer_legacy_doc_native | `.doc` 是否优先尝试原生处理能力 | +| allow_docx_fallback | 原生 `.doc` 能力不可用或失败时是否允许 `.docx` 兜底 | + +--- + +## 八、字段抽取设计 + +### 8.1 说明书解析 + +`instruction_extract.py` 输出: + +| 数据 | 说明 | +| --- | --- | +| paragraphs | 按顺序提取段落 | +| sections | 按 `【章节名】` 切分 | +| tables | 提取表格二维数据 | +| component_tables | 识别主要组成成分表 | +| front_text | 前 4000 字,供 LLM 使用 | + +### 8.2 规则抽取 + +规则抽取覆盖: + +| 字段 | 规则 | +| --- | --- | +| product_name | `【产品名称】` 下一段 | +| package_specification | `【包装规格】` 到下一章节 | +| intended_use | `【预期用途】` 到下一章节 | +| detection_principle | `【检测原理】` 到下一章节 | +| main_components | `【主要组成成分】` 表格摘要 | +| storage_condition_and_validity | `【储存条件及有效期】` 到下一章节 | +| sample_type | `样本要求` 中“适用样本类型” | +| detection_targets | 从预期用途/检测原理中抽取基因、病原体、靶标 | +| applicable_instruments | `【适用仪器】` 到下一章节 | +| test_method | `【检验方法】` 摘要 | +| standards | 正则抽取 `GB/T`、`YY/T`、`YY`、`GB` 等标准号 | + +### 8.3 LLM 抽取 + +LLM prompt 要求只输出 JSON: + +```json +{ + "fields": [ + { + "key": "product_name", + "label": "产品名称", + "value": "...", + "evidence": "...", + "confidence": 0.9 + } + ], + "product_list_rows": [ + { + "package_specification": "...", + "composition": "...", + "component_name": "...", + "main_component": "...", + "quantity": "..." + } + ], + "standards": [] +} +``` + +LLM 不允许填企业信息、分类编码、管理类别、临床评价路径等说明书无法证明的内容。 + +### 8.4 字段合并 + +`field_merge.py` 输出 `MergedField`: + +| 字段 | 说明 | +| --- | --- | +| key | 字段编码 | +| label | 中文名 | +| value | 最终写入值 | +| source | rule、llm、missing、conflict | +| evidence | 来源片段 | +| confidence | 置信度 | +| highlight_reason | none、missing、llm_only、conflict、rag_candidate | +| needs_review | 是否需人工复核 | + +合并规则: + +| 场景 | 处理 | +| --- | --- | +| rule 与 LLM 一致 | 采用值,不高亮 | +| rule 与 LLM 不一致 | 采用规则优先或配置优先,标记 conflict | +| rule 缺失、LLM 命中 | 采用 LLM 值,标记 llm_only | +| 全部缺失 | 写 `/`,标记 missing | + +--- + +## 九、文档生成设计 + +### 9.1 文档适配器接口 + +`document_writer.py` 定义统一接口: + +```python +class DocumentAdapter: + def replace_text(self, old: str, new: str, *, highlight: bool = False) -> int: ... + def fill_table_cell(self, row_label: str, value: str, *, highlight: bool = False) -> bool: ... + def replace_table(self, marker: str, rows: list[dict], *, highlight_columns: list[str] = None) -> bool: ... + def highlight_value(self, value: str, reason: str) -> int: ... + def save(self, path: Path) -> Path: ... +``` + +`.docx` 使用 `DocxDocumentAdapter`。`.doc` 使用 `LegacyDocDocumentAdapter`。 + +### 9.2 `.docx` 处理 + +能力: + +| 能力 | 实现 | +| --- | --- | +| 段落替换 | 遍历 paragraph runs | +| 表格行填充 | 按首列 label 定位 | +| 单元格高亮 | `w:shd` 黄色底色 | +| 字体颜色 | 冲突项可红色字体 | +| 产品列表重建 | 清空目标表格数据行后追加 | +| 声明日期替换 | 按日期正则或段落末尾替换 | + +### 9.3 `.doc` 处理 + +设计 `LegacyDocDocumentAdapter`,对外提供与 `.docx` 一致能力。底层按可用性选择适配器: + +| 适配器 | 定位 | +| --- | --- | +| `WordComDocAdapter` | Windows + Microsoft Word 环境下优先,直接打开 `.doc`、查找替换、设置高亮并保存 `.doc` | +| `LibreOfficeUnoDocAdapter` | LibreOffice UNO/API 环境下使用,直接操作文档模型 | +| `OleDocReadOnlyAdapter` | 仅可读取时用于诊断,不满足写入验收 | +| `ConversionFallbackAdapter` | 兜底路径,可转换为 `.docx` 后处理,但不能作为唯一实现 | + +功能设计约束: + +| 约束 | 说明 | +| --- | --- | +| 不静默降级 | `.doc` 原生写入失败时必须记录适配器失败原因,随后尝试 `.docx` 兜底;兜底仍失败时该文件失败并触发 partial_success | +| 不只靠转换 | 转换可作为兜底,但设计主路径必须是文档适配器 | +| 能力探测 | 启动时或节点执行时检测适配器可用性 | +| 追溯记录 | 写入 `.doc` 的适配器类型和失败信息写入 artifact metadata | + +### 9.4 7 个文件生成策略 + +| 模板 | 策略服务 | 关键动作 | +| --- | --- | --- | +| CH1.2 监管信息目录 | `generate_directory_doc` | 替换产品名称;页码沿用样例 | +| CH1.4 申请表 | `generate_application_form_doc` | 填表格行;缺失字段 `/` 黄底 | +| CH1.5 产品列表 | `generate_product_list_doc` | 使用样例表头重建产品列表;货号 `/` 黄底 | +| CH1.9 申报前沟通说明 | `generate_pre_submission_doc` | `.doc` 原生替换产品名和公司名;原生失败则输出 `.docx` 兜底文件;两者均失败才不进入 zip | +| CH1.11.1 符合标准清单 | `generate_standard_list_doc` | 说明书标准号直接写;候选/缺失高亮 | +| CH1.11.5 真实性声明 | `generate_authenticity_statement_doc` | 保留正文,替换产品名,公司名 `/` 黄底,日期当天 | +| CH1.11.6 符合性声明 | `generate_compliance_statement_doc` | 保留正文,替换产品名,公司名 `/` 黄底,日期当天 | + +`generate_docs` 节点内部允许多线程并发处理 7 个目标文件。每个文档使用独立模板副本,子线程只返回生成结果,数据库 artifact/export 记录由主线程统一写入,避免并发写库和共享文件冲突。 + +--- + +## 十、标准清单设计 + +系统中已有知识库/RAG 能力,不新增单独 RAG 模块。本功能只新增 `standard_candidates.py` 作为业务服务,调用既有知识库搜索能力。 + +处理规则: + +| 来源 | 处理 | +| --- | --- | +| 说明书明确标准号 | 写入标准清单,记录 `source=instruction` | +| 知识库候选标准 | 可写入候选区或追溯清单,标记 `rag_candidate` 并高亮 | +| 无命中 | 写 `/` 并黄底 | +| 样例标准 | 不无条件沿用 | + +查询建议: + +```text +体外诊断试剂 核酸扩增 检测试剂 标准 清单 +新型冠状病毒 2019-nCoV 核酸检测试剂盒 荧光PCR 标准 +``` + +--- + +## 十一、zip 与导出设计 + +### 11.1 ExportType 扩展 + +`ExportedSummaryFile.ExportType` 增加: + +```python +ZIP = "zip", "ZIP" +``` + +下载 content type 增加: + +```python +"zip": "application/zip" +``` + +### 11.2 导出记录 + +| 文件 | export_category | export_type | +| --- | --- | --- | +| 第1章 监管信息(预生成版).zip | regulatory_info_package | zip | +| 7 个生成文件 | generated_document | word 或 legacy_word | +| 追溯清单 Excel | traceability | excel | + +追溯 JSON 和抽取过程 JSON 只保存到后台 `logs/` 目录和 artifact 记录,不作为用户下载入口。用户侧只提供追溯 Excel 下载。 + +如果不新增 `legacy_word` export_type,则 `.doc` 也可暂用 `word`,通过文件扩展名和 content type 判断下载 MIME。功能设计建议新增 content type 映射时按扩展名兜底,避免 `.doc` 被当作 `.docx`。 + +### 11.3 权限 + +`file_summary.views._export_for_user` 增加: + +```text +if exported.workflow_type == "regulatory_info_package": + 查询 RegulatoryInfoPackageBatch + 校验 conversation__user == request.user 且 is_deleted=False +``` + +--- + +## 十二、数据模型设计 + +### 12.1 RegulatoryInfoPackageBatch + +```python +class RegulatoryInfoPackageBatch(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "待执行" + RUNNING = "running", "执行中" + WAITING_USER = "waiting_user", "等待用户" + SUCCESS = "success", "成功" + PARTIAL_SUCCESS = "partial_success", "部分成功" + FAILED = "failed", "失败" + CANCELLED = "cancelled", "已取消" +``` + +字段建议: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| conversation | FK Conversation | 所属对话 | +| user | FK User | 发起用户 | +| trigger_message | FK Message | 触发消息 | +| source_attachment | FK FileAttachment | 直接选中的说明书附件 | +| source_summary_batch | FK FileSummaryBatch | 可选文件汇总批次 | +| source_summary_item_id | PositiveBigIntegerField | 可选汇总条目 ID | +| batch_no | CharField unique | RIP 批次号 | +| status | CharField | 状态 | +| source_file_name | CharField | 说明书原文件名 | +| source_storage_path | CharField | 说明书路径 | +| product_name | CharField | 抽取产品名 | +| output_zip_name | CharField | zip 文件名 | +| generated_files | JSONField | 7 个文件状态 | +| missing_fields | JSONField | 缺失项 | +| llm_only_fields | JSONField | LLM-only 项 | +| conflict_fields | JSONField | 冲突项 | +| risk_notes | JSONField | 风险提示 | +| template_config_version | CharField | 配置版本 | +| template_config_hash | CharField | 配置 hash | +| adapter_summary | JSONField | `.doc`/`.docx` 适配器信息 | +| work_dir | CharField | 工作目录 | +| error_message | TextField | 错误信息 | +| started_at/finished_at | DateTimeField | 执行时间 | +| is_deleted | BooleanField | 软删除 | + +索引: + +| 索引 | 字段 | +| --- | --- | +| idx_ra_rip_batch_conv_status | conversation, status | +| idx_ra_rip_batch_user_created | user, created_at | +| idx_ra_rip_batch_attachment | source_attachment | +| idx_ra_rip_batch_summary | source_summary_batch | + +### 12.2 RegulatoryInfoPackageArtifact + +产物类型: + +| 类型 | 说明 | +| --- | --- | +| template_copy | 模板副本 | +| instruction_extract | 说明书抽取结果 | +| field_extract_result | 字段抽取结果 | +| merged_fields | 合并字段 | +| generated_document | 生成文件 | +| traceability | 追溯清单 | +| zip_package | zip 包 | +| notification_record | 通知记录 | + +字段与 `ApplicationFormFillArtifact` 保持一致:`batch`、`artifact_type`、`file_format`、`name`、`file_name`、`storage_path`、`file_size`、`content_hash`、`metadata`、`created_by_node`、`is_deleted`。 + +`file_format` 增加 `DOC`、`ZIP`。 + +### 12.3 RegulatoryInfoPackageNotificationRecord + +结构对齐 `ApplicationFormFillNotificationRecord`: + +| 字段 | 说明 | +| --- | --- | +| batch | 所属 RIP 批次 | +| recipient | 通知对象 | +| channel | feishu_cli、feishu_api、mock | +| export_ids | 导出 ID | +| message_summary | 通知摘要 | +| send_status | pending、success、failed | +| retry_count | 重试次数 | +| external_message_id | 外部消息 ID | +| error_message | 错误 | +| sent_at | 发送时间 | + +--- + +## 十三、工作流设计 + +### 13.1 节点定义 + +| 节点编码 | 节点名称 | 触发服务 | 成功条件 | 失败处理 | +| --- | --- | --- | --- | --- | +| prepare | 准备资料 | `RegulatoryInfoPackageWorkflowExecutor` | 找到唯一说明书 | 缺失或多候选进入 waiting_user | +| template_copy | 复制模板 | `TemplateRepository` | 7 个模板进入批次目录 | 缺关键模板则 failed | +| text_extract | 抽取说明书 | `InstructionExtractService` | 提取文本、章节和表格 | 失败则 failed | +| field_extract | 抽取字段 | `FieldExtractionService` | 规则/LLM 结果留底 | LLM 失败可继续 | +| field_merge | 合并字段 | `FieldMergeService` | 输出 merged_fields | 无产品名仍继续,产品名 `/` | +| generate_docs | 生成材料 | `PackageGenerateService` | 生成 7 个文件 | 单文件失败可 partial_success | +| highlight_review_items | 标记待确认 | 文档适配器 | 缺失/LLM-only/冲突完成高亮 | 失败则对应文件失败 | +| trace_export | 追溯清单 | `TraceabilityExportService` | 生成 Excel/JSON | 不阻断 zip | +| zip_export | 打包下载 | `ZipExportService` | 生成 zip 并创建导出记录 | zip 失败则保留单文件 | +| notify | 通知 | `Notifier` | 写通知记录 | 不阻断下载 | +| completed | 完成 | Executor | 状态落定、摘要写入对话 | - | + +### 13.2 状态落定 + +| 结果 | 批次状态 | +| --- | --- | +| 7 个文件、zip、追溯清单均成功 | success | +| zip 成功但部分单文件/追溯/通知失败 | partial_success | +| 单文件成功但 zip 失败 | partial_success | +| 关键输入或模板缺失 | failed 或 waiting_user | +| 所有目标文件生成失败 | failed | + +--- + +## 十四、接口设计 + +### 14.1 URL + +```text +GET /api/review-agent/regulatory-info-package/health/ +POST /api/review-agent/regulatory-info-package/start/ +GET /api/review-agent/regulatory-info-package//status/ +POST /api/review-agent/regulatory-info-package//select-input/ +``` + +### 14.2 start + +请求: + +```json +{ + "conversation_id": 1, + "attachment_id": 10, + "file_summary_batch_id": 20, + "source_summary_item_id": 30 +} +``` + +响应: + +```json +{ + "batch_id": 1, + "workflow_type": "regulatory_info_package", + "batch_no": "RIP-20260610153000-abcdef", + "status": "pending" +} +``` + +### 14.3 status + +响应包含: + +| 字段 | 说明 | +| --- | --- | +| batch | 批次基础信息、产品名、缺失数、LLM-only 数、冲突数 | +| nodes | 工作流节点 | +| generated_files | 7 个文件状态 | +| exports | zip、单文件、追溯清单下载 | +| missing_fields | 缺失项摘要 | +| llm_only_fields | LLM-only 摘要 | +| conflict_fields | 冲突摘要 | +| risk_notes | 风险提示 | +| notifications | 通知记录 | + +--- + +## 十五、前端设计 + +### 15.1 对话框底部快捷提示 + +`templates/home.html` 增加 tool chip: + +```text +根据说明书生成第1章监管信息 +``` + +点击后填入 prompt,不自动发送,保持现有交互一致。 + +### 15.2 工作流卡片 + +`build_workflow_cards()` 增加 RIP 批次,前端复用现有卡片样式,展示: + +| 信息 | 说明 | +| --- | --- | +| 批次号 | RIP-... | +| 状态 | pending/running/success/partial_success/failed | +| 风险摘要 | 缺失字段 N、LLM复核 N、提示 N | +| 节点 | RIP 节点 | + +### 15.3 状态轮询 + +`summaryPanel` 增加: + +```html +data-regulatory-info-package-status-url-template="/api/review-agent/regulatory-info-package/__batch_id__/status/" +``` + +`static/js/app.js` 在工作流类型判断中增加 `regulatory_info_package`。 + +### 15.4 结果展示 + +状态 payload 中 `exports` 按类别展示: + +| 类别 | 展示 | +| --- | --- | +| zip | 主下载按钮 | +| generated_document | 单文件下载列表 | +| traceability | 追溯清单下载 | + +--- + +## 十六、通知设计 + +复用统一通知服务,新增 `build_regulatory_info_package_context(batch)`: + +| 摘要项 | 说明 | +| --- | --- | +| 工作流 | 第1章监管信息材料包生成 | +| 批次号 | RIP-... | +| 产品名称 | 抽取产品名 | +| 导出文件 | zip + 单文件数量 | +| 待确认 | 缺失项、LLM-only、冲突项数量 | +| 下载提示 | 进入系统下载 zip | + +通知失败不影响下载。 + +--- + +## 十七、异常与降级 + +| 异常 | 处理 | +| --- | --- | +| 未找到说明书 | 返回提示,不创建或创建 waiting_user 批次 | +| 多说明书候选 | waiting_user,等待选择 | +| YAML 配置错误 | failed,提示配置错误 | +| 样例模板缺失 | failed,列出缺失模板 | +| LLM 失败 | 使用规则抽取继续,写 risk_notes | +| 规则抽取为空 | 使用 LLM-only 继续并高亮 | +| 知识库不可用 | 标准清单填 `/` 并高亮,写 risk_notes | +| `.doc` 适配器不可用 | CH1.9 失败,批次 partial_success 或 failed,明确原因 | +| zip 打包失败 | 保留单文件下载,状态 partial_success | +| 下载文件不存在 | 返回 404,记录日志 | + +--- + +## 十八、安全与权限 + +| 控制点 | 设计 | +| --- | --- | +| 批次访问 | `conversation__user == request.user` | +| 附件访问 | 附件必须属于当前对话和当前用户 | +| 汇总批次访问 | 批次必须属于当前对话和当前用户 | +| 导出下载 | `workflow_type=regulatory_info_package` 时反查 RIP 批次 | +| 工作目录 | `media/regulatory_info_package/{user_id}/{conversation_id}/{batch_no}` | +| 路径安全 | 所有复制/输出路径必须校验位于批次工作目录内 | +| 原始模板保护 | 只读复制,不允许覆盖 `docs/0.原始材料` | + +--- + +## 十九、测试设计 + +| 测试文件 | 覆盖 | +| --- | --- | +| `tests/test_regulatory_info_package_models.py` | 批次、产物、通知、zip 导出类型 | +| `tests/test_regulatory_info_package_trigger.py` | 固定规则与 LLM 路由 | +| `tests/test_regulatory_info_package_input_select.py` | 说明书选择、多候选 waiting_user | +| `tests/test_regulatory_info_package_template_config.py` | YAML 加载、模板存在性校验 | +| `tests/test_regulatory_info_package_field_extract.py` | 说明书字段、表格、标准号抽取 | +| `tests/test_regulatory_info_package_field_merge.py` | missing、llm_only、conflict 高亮决策 | +| `tests/test_regulatory_info_package_docx_writer.py` | docx 替换、表格填充、黄底 | +| `tests/test_regulatory_info_package_legacy_doc.py` | `.doc` 适配器能力探测和失败提示 | +| `tests/test_regulatory_info_package_zip.py` | zip 只包含 success/fallback_success 文件 | +| `tests/test_regulatory_info_package_workflow.py` | 工作流节点和状态落定 | +| `tests/test_regulatory_info_package_views.py` | start/status/权限 | +| `tests/test_regulatory_info_package_frontend.py` | 卡片、快捷提示、状态 URL | + +回归测试: + +```bash +python manage.py check +pytest tests/test_application_form_fill_*.py tests/test_file_summary_views.py tests/test_regulatory_*tests.py +``` + +实际执行时按项目现有测试命名拆分运行。 + +--- + +## 二十、实施顺序建议 + +| 阶段 | 内容 | +| --- | --- | +| RIP-1 | 模型、迁移、ExportType.ZIP、下载权限 | +| RIP-2 | 模块骨架、YAML 配置、输入说明书选择 | +| RIP-3 | 路由 action、对话启动、工作流节点 | +| RIP-4 | 说明书文本/表格抽取、规则 + LLM 字段抽取 | +| RIP-5 | docx 文档生成、黄底高亮、产品列表重建 | +| RIP-6 | `.doc` 适配器、CH1.9 处理能力 | +| RIP-7 | 追溯清单、zip 导出、助手摘要 | +| RIP-8 | 前端卡片、快捷提示、状态轮询 | +| RIP-9 | 通知、权限、全量回归 | + +--- + +## 二十一、待确认与风险 + +| 风险 | 说明 | 建议 | +| --- | --- | --- | +| `.doc` 原生写入难度 | Python 标准库不支持 Word `.doc` 完整写入 | 优先调研 Word COM 或 LibreOffice UNO;无原生能力时允许可追溯 `.docx` 兜底 | +| 模板字段化工作量 | 需要先把样例模板整理为代码可识别字段 | 优先覆盖 CH1.4、CH1.5 和声明类关键字段;缺少 Tag 时通过模板审计提前暴露 | +| 样例模板文本碎片 | Word run 拆分可能导致简单字符串替换失败 | 文档写入服务需支持跨 run 替换 | +| 产品列表结构复杂 | 说明书表格可能存在合并单元格和多规格 | 先覆盖目标说明书结构,再扩展通用表格归一化 | +| 标准清单准确性 | 说明书未必包含标准号,知识库候选不能直接作为结论 | 候选全部高亮并进入追溯清单 | +| LLM-only 风险 | LLM 推断可能过度补全 | 写入但高亮,追溯清单标记需复核 | + +--- + +## 二十二、设计结论 + +| 编号 | 结论 | +| --- | --- | +| D1 | 功能设计文档新增为 `docs/2.功能设计/5.第1章监管信息材料包生成.md` | +| D2 | 新增独立模块 `review_agent/regulatory_info_package/` | +| D3 | 新建独立批次、产物、通知三张表 | +| D4 | 输入选择以 active 附件为主,兼容最近成功文件汇总批次 | +| D5 | `ExportedSummaryFile.ExportType` 扩展 `zip` | +| D6 | 采用 YAML 配置驱动 7 个模板 | +| D7 | 模板字段优先使用内容控件 Tag 或稳定占位符,行标签定位仅作为兜底 | +| D8 | `.doc` 通过 `LegacyWordDocumentService` 适配器实现与 `.docx` 等价接口,原生能力不可用时允许可追溯兜底 | +| D9 | 标准候选复用系统已有知识库/RAG,不新增独立 RAG | +| D10 | 前端只扩展现有对话页、工作流卡片、快捷提示和状态轮询 | +| D11 | 本轮先产出功能设计;数据库设计先在本文档中给出,后续可拆成正式数据库设计文档 | diff --git a/docs/3.数据库设计/1.自动汇总.md b/docs/3.数据库设计/1.自动汇总.md new file mode 100644 index 0000000..5ae7641 --- /dev/null +++ b/docs/3.数据库设计/1.自动汇总.md @@ -0,0 +1,651 @@ +# 自动汇总文件夹文件目录与页数流程数据库设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/1.自动汇总.md | +| 功能设计文档 | docs/2.功能设计/1.自动汇总.md | +| 详细设计文档 | docs/3.详细设计/1.自动汇总.md | +| 数据库类型 | SQLite / Django ORM | +| 表名前缀 | ra_ | +| 设计日期 | 2026-06-05 | +| 设计版本 | V1.0 | + +--- + +## 一、设计原则 + +| 原则 | 说明 | +| --- | --- | +| ORM 优先 | 当前项目使用 Django,实际落地以 Django Model 与 migration 为准 | +| SQLite 兼容 | 字段类型、索引和约束优先保证 SQLite 可运行 | +| 短表名前缀 | 使用 `ra_` 作为审核智能体文件汇总相关表前缀 | +| 不建枚举表 | 状态枚举使用 Django `TextChoices`,数据库存储字符串 | +| 对话隔离 | 所有附件、批次、导出文件均可追溯到 Conversation 和 User | +| 多版本附件 | 同一对话同名附件允许多次上传,以版本号区分 | +| 批次固化 | 每次汇总批次通过中间表绑定本次使用的附件版本,防止串文件 | +| 事件留痕 | 保留 WorkflowEvent,用于 SSE 断线续传、页面刷新恢复和排查问题 | + +--- + +## 二、ER 图 + +```mermaid +erDiagram + AUTH_USER ||--o{ CONVERSATION : owns + CONVERSATION ||--o{ MESSAGE : contains + CONVERSATION ||--o{ RA_FILE_ATTACHMENT : has + CONVERSATION ||--o{ RA_FILE_SUMMARY_BATCH : has + AUTH_USER ||--o{ RA_FILE_ATTACHMENT : uploads + AUTH_USER ||--o{ RA_FILE_SUMMARY_BATCH : runs + MESSAGE ||--o{ RA_FILE_SUMMARY_BATCH : triggers + RA_FILE_SUMMARY_BATCH ||--o{ RA_FILE_SUMMARY_BATCH_ATTACHMENT : binds + RA_FILE_ATTACHMENT ||--o{ RA_FILE_SUMMARY_BATCH_ATTACHMENT : selected + RA_FILE_SUMMARY_BATCH ||--o{ RA_FILE_SUMMARY_ITEM : produces + RA_FILE_SUMMARY_BATCH ||--o{ RA_WORKFLOW_NODE_RUN : tracks + RA_FILE_SUMMARY_BATCH ||--o{ RA_WORKFLOW_EVENT : emits + RA_FILE_SUMMARY_BATCH ||--o{ RA_EXPORTED_SUMMARY_FILE : exports +``` + +--- + +## 三、表结构设计 + +### 3.1 ra_file_attachment + +用户在对话右侧上传区上传后的附件记录。上传即存储,不代表已启动工作流。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| conversation_id | ForeignKey | bigint | 是 | 绑定对话 | +| user_id | ForeignKey | bigint | 是 | 上传用户 | +| original_name | CharField(255) | varchar(255) | 是 | 原始文件名 | +| version_no | PositiveIntegerField | integer | 是 | 同一对话同名文件版本号,从 1 递增 | +| is_active | BooleanField | bool | 是 | 是否当前默认版本 | +| storage_path | CharField(500) | varchar(500) | 是 | 文件存储路径 | +| file_size | BigIntegerField | bigint | 是 | 文件大小 | +| content_type | CharField(120) | varchar(120) | 否 | MIME 类型 | +| upload_status | CharField(20) | varchar(20) | 是 | uploaded、bound、deleted | +| created_at | DateTimeField | datetime | 是 | 上传时间 | + +唯一约束: + +| 约束名 | 字段 | +| --- | --- | +| uq_ra_attachment_conv_name_version | conversation_id, original_name, version_no | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_attachment_conv_created | conversation_id, created_at | 查询对话附件列表 | +| idx_ra_attachment_user_created | user_id, created_at | 查询用户上传记录 | +| idx_ra_attachment_active | conversation_id, original_name, is_active | 查询当前默认版本 | + +--- + +### 3.2 ra_file_summary_batch + +一次文件目录与页数汇总工作流批次。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| conversation_id | ForeignKey | bigint | 是 | 绑定对话 | +| user_id | ForeignKey | bigint | 是 | 执行用户 | +| trigger_message_id | ForeignKey | bigint | 否 | 触发工作流的用户消息 | +| batch_no | CharField(64) | varchar(64) | 是 | 批次编号,唯一 | +| product_name | CharField(200) | varchar(200) | 否 | 识别出的产品名称 | +| status | CharField(20) | varchar(20) | 是 | pending、running、success、failed | +| total_files | IntegerField | integer | 是 | 文件总数 | +| supported_files | IntegerField | integer | 是 | 支持统计文件数 | +| success_files | IntegerField | integer | 是 | 统计成功文件数 | +| failed_files | IntegerField | integer | 是 | 统计失败文件数 | +| unsupported_files | IntegerField | integer | 是 | 不支持文件数 | +| uncertain_files | IntegerField | integer | 是 | 页数不可确定文件数 | +| total_pages | IntegerField | integer | 是 | 总页数 | +| work_dir | CharField(500) | varchar(500) | 否 | 批次工作目录 | +| error_message | TextField | text | 否 | 批次异常说明 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | +| started_at | DateTimeField | datetime | 否 | 开始时间 | +| finished_at | DateTimeField | datetime | 否 | 完成时间 | + +唯一约束: + +| 约束名 | 字段 | +| --- | --- | +| uq_ra_batch_no | batch_no | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_batch_conv_created | conversation_id, created_at | 查询对话下批次 | +| idx_ra_batch_user_created | user_id, created_at | 查询用户批次 | +| idx_ra_batch_status | status, created_at | 查询执行中或失败批次 | + +--- + +### 3.3 ra_file_summary_batch_attachment + +批次与附件版本绑定表。一个对话可多次上传同名附件,批次必须固化本次使用的附件版本。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| batch_id | ForeignKey | bigint | 是 | 汇总批次 | +| attachment_id | ForeignKey | bigint | 是 | 本次使用的附件版本 | +| source_role | CharField(20) | varchar(20) | 是 | archive、multi_file | +| created_at | DateTimeField | datetime | 是 | 绑定时间 | + +唯一约束: + +| 约束名 | 字段 | +| --- | --- | +| uq_ra_batch_attachment | batch_id, attachment_id | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_batch_attachment_batch | batch_id, created_at | 查询批次附件 | +| idx_ra_batch_attachment_attachment | attachment_id | 查询附件被哪些批次使用 | + +--- + +### 3.4 ra_file_summary_item + +文件明细表,记录扫描到的每个文件及页数统计结果。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| batch_id | ForeignKey | bigint | 是 | 所属批次 | +| file_index | PositiveIntegerField | integer | 是 | 文件序号 | +| directory_level | CharField(300) | varchar(300) | 否 | 目录层级 | +| file_name | CharField(255) | varchar(255) | 是 | 文件名 | +| file_type | CharField(20) | varchar(20) | 是 | 文件类型 | +| relative_path | CharField(500) | varchar(500) | 是 | 相对路径,用于展示和导出 | +| storage_path | CharField(500) | varchar(500) | 是 | 实际处理路径 | +| page_count | IntegerField | integer | 否 | 页数,失败或不可确定时为空 | +| statistics_status | CharField(20) | varchar(20) | 是 | success、failed、unsupported、uncertain、skipped | +| retry_count | PositiveIntegerField | integer | 是 | 页数统计重试次数 | +| error_message | TextField | text | 否 | 异常说明 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | +| updated_at | DateTimeField | datetime | 是 | 更新时间 | + +唯一约束: + +| 约束名 | 字段 | +| --- | --- | +| uq_ra_item_batch_relative_path | batch_id, relative_path | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_item_batch_index | batch_id, file_index | 按序展示文件明细 | +| idx_ra_item_batch_status | batch_id, statistics_status | 查询失败/不可确定文件 | +| idx_ra_item_batch_type | batch_id, file_type | 按类型统计 | + +--- + +### 3.5 ra_workflow_node_run + +工作流节点运行状态表,用于右侧工作流卡片状态恢复。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| batch_id | ForeignKey | bigint | 是 | 所属批次 | +| node_code | CharField(40) | varchar(40) | 是 | 节点编码 | +| node_name | CharField(80) | varchar(80) | 是 | 节点名称 | +| status | CharField(20) | varchar(20) | 是 | pending、running、retrying、success、failed、skipped | +| progress | PositiveIntegerField | integer | 是 | 进度百分比,0-100 | +| message | TextField | text | 否 | 节点提示 | +| started_at | DateTimeField | datetime | 否 | 开始时间 | +| finished_at | DateTimeField | datetime | 否 | 完成时间 | + +唯一约束: + +| 约束名 | 字段 | +| --- | --- | +| uq_ra_node_batch_code | batch_id, node_code | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_node_batch_status | batch_id, status | 查询批次节点状态 | + +--- + +### 3.6 ra_workflow_event + +工作流事件表,用于 SSE 事件持久化、断线续传和调试。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键,同时可作为 event_id | +| batch_id | ForeignKey | bigint | 是 | 所属批次 | +| event_type | CharField(40) | varchar(40) | 是 | workflow_started、node_progress 等 | +| payload | JSONField | text/json | 是 | 事件载荷 | +| created_at | DateTimeField | datetime | 是 | 事件时间 | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_event_batch_id | batch_id, id | SSE after 续传 | +| idx_ra_event_batch_created | batch_id, created_at | 按时间查询事件 | + +--- + +### 3.7 ra_exported_summary_file + +导出文件记录表。下载链接运行时根据 export_id 生成。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| batch_id | ForeignKey | bigint | 是 | 所属批次 | +| export_type | CharField(20) | varchar(20) | 是 | markdown、excel | +| file_name | CharField(255) | varchar(255) | 是 | 导出文件名 | +| storage_path | CharField(500) | varchar(500) | 是 | 保存路径 | +| status | CharField(20) | varchar(20) | 是 | success、failed | +| error_message | TextField | text | 否 | 导出异常说明 | +| created_at | DateTimeField | datetime | 是 | 生成时间 | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_export_batch_type | batch_id, export_type | 查询批次导出文件 | +| idx_ra_export_batch_created | batch_id, created_at | 按生成时间查询 | + +--- + +## 四、枚举设计 + +本功能不建立枚举表,枚举通过 Django `TextChoices` 定义,数据库存储字符串。 + +### 4.1 附件状态 upload_status + +| 值 | 中文 | 说明 | +| --- | --- | --- | +| uploaded | 已上传 | 上传完成,尚未绑定批次 | +| bound | 已绑定 | 已被某个批次使用 | +| deleted | 已删除 | 用户逻辑删除,不再作为默认候选 | + +### 4.2 批次状态 batch.status + +| 值 | 中文 | 说明 | +| --- | --- | --- | +| pending | 待执行 | 批次已创建 | +| running | 执行中 | 后台工作流运行中 | +| success | 成功 | 工作流完成 | +| failed | 失败 | 批次级失败 | + +### 4.3 节点状态 node.status + +| 值 | 中文 | 说明 | +| --- | --- | --- | +| pending | 等待中 | 节点未开始 | +| running | 执行中 | 节点正在执行 | +| retrying | 重试中 | 单文件解析失败后重试 | +| success | 成功 | 节点执行成功 | +| failed | 失败 | 节点失败 | +| skipped | 跳过 | 当前批次不需要执行该节点 | + +### 4.4 文件统计状态 statistics_status + +| 值 | 中文 | 说明 | +| --- | --- | --- | +| success | 成功 | 页数统计成功 | +| failed | 失败 | 重试后仍失败 | +| unsupported | 不支持 | 文件类型不在支持范围 | +| uncertain | 不确定 | 文件可读,但无可靠页数元数据 | +| skipped | 跳过 | 空文件、隐藏文件或规则跳过 | + +### 4.5 导出类型 export_type + +| 值 | 中文 | 说明 | +| --- | --- | --- | +| markdown | Markdown | Markdown 汇总报告 | +| excel | Excel | Excel 明细文件 | + +### 4.6 导出状态 export.status + +| 值 | 中文 | 说明 | +| --- | --- | --- | +| success | 成功 | 导出文件生成成功 | +| failed | 失败 | 导出失败 | + +--- + +## 五、关系与业务规则 + +### 5.1 对话与附件 + +```text +Conversation 1:N ra_file_attachment +``` + +规则: + +| 规则 | 说明 | +| --- | --- | +| 上传即存储 | 用户上传后立即创建 FileAttachment | +| 对话隔离 | 附件只能被同一 Conversation 下的批次使用 | +| 多版本 | 同一 conversation + original_name 可存在多个 version_no | +| 默认版本 | is_active=true 的记录作为默认候选版本 | +| 逻辑删除 | 删除附件时设置 upload_status=deleted,不立即物理删除 | + +### 5.2 对话与批次 + +```text +Conversation 1:N ra_file_summary_batch +``` + +规则: + +| 规则 | 说明 | +| --- | --- | +| 多次汇总 | 同一对话允许多次触发自动汇总 | +| 提示词触发 | 批次由用户消息触发,可关联 trigger_message_id | +| 批次固化 | 批次启动时固化本次使用的附件版本 | + +### 5.3 批次与附件版本 + +```text +ra_file_summary_batch N:M ra_file_attachment +``` + +通过 `ra_file_summary_batch_attachment` 实现。 + +规则: + +| 规则 | 说明 | +| --- | --- | +| 不串文件 | 工作流只能读取中间表绑定的附件 | +| 保留历史 | 即使附件后续上传新版本,历史批次仍指向旧版本 | +| 版本选择 | 用户未选择时默认使用同名文件的最新 active 版本 | + +### 5.4 批次与文件明细 + +```text +ra_file_summary_batch 1:N ra_file_summary_item +``` + +规则: + +| 规则 | 说明 | +| --- | --- | +| 相对路径唯一 | 同一批次下 relative_path 唯一 | +| 处理路径保留 | relative_path 用于展示,storage_path 用于后台处理 | +| 单文件失败不阻断 | 文件解析失败记录 failed,批次继续处理其他文件 | + +--- + +## 六、索引设计汇总 + +| 表 | 索引/约束 | 字段 | 用途 | +| --- | --- | --- | --- | +| ra_file_attachment | uq_ra_attachment_conv_name_version | conversation_id, original_name, version_no | 同名附件版本唯一 | +| ra_file_attachment | idx_ra_attachment_conv_created | conversation_id, created_at | 对话附件列表 | +| ra_file_attachment | idx_ra_attachment_user_created | user_id, created_at | 用户上传记录 | +| ra_file_attachment | idx_ra_attachment_active | conversation_id, original_name, is_active | 默认版本查询 | +| ra_file_summary_batch | uq_ra_batch_no | batch_no | 批次编号唯一 | +| ra_file_summary_batch | idx_ra_batch_conv_created | conversation_id, created_at | 对话批次列表 | +| ra_file_summary_batch | idx_ra_batch_user_created | user_id, created_at | 用户批次列表 | +| ra_file_summary_batch | idx_ra_batch_status | status, created_at | 查询运行中/失败批次 | +| ra_file_summary_batch_attachment | uq_ra_batch_attachment | batch_id, attachment_id | 批次附件唯一 | +| ra_file_summary_item | uq_ra_item_batch_relative_path | batch_id, relative_path | 批次内文件唯一 | +| ra_file_summary_item | idx_ra_item_batch_index | batch_id, file_index | 文件明细排序 | +| ra_file_summary_item | idx_ra_item_batch_status | batch_id, statistics_status | 查询异常文件 | +| ra_workflow_node_run | uq_ra_node_batch_code | batch_id, node_code | 每批次每节点唯一 | +| ra_workflow_event | idx_ra_event_batch_id | batch_id, id | SSE 断点续传 | +| ra_exported_summary_file | idx_ra_export_batch_type | batch_id, export_type | 查询导出文件 | + +--- + +## 七、SQLite 参考 DDL + +> 说明:以下 DDL 为设计参考,实际落地以 Django migration 为准。 + +```sql +CREATE TABLE ra_file_attachment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + original_name VARCHAR(255) NOT NULL, + version_no INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT 1, + storage_path VARCHAR(500) NOT NULL, + file_size BIGINT NOT NULL DEFAULT 0, + content_type VARCHAR(120) NOT NULL DEFAULT '', + upload_status VARCHAR(20) NOT NULL DEFAULT 'uploaded', + created_at DATETIME NOT NULL, + UNIQUE (conversation_id, original_name, version_no) +); + +CREATE INDEX idx_ra_attachment_conv_created +ON ra_file_attachment (conversation_id, created_at); + +CREATE INDEX idx_ra_attachment_user_created +ON ra_file_attachment (user_id, created_at); + +CREATE INDEX idx_ra_attachment_active +ON ra_file_attachment (conversation_id, original_name, is_active); +``` + +```sql +CREATE TABLE ra_file_summary_batch ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + trigger_message_id BIGINT NULL, + batch_no VARCHAR(64) NOT NULL UNIQUE, + product_name VARCHAR(200) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + total_files INTEGER NOT NULL DEFAULT 0, + supported_files INTEGER NOT NULL DEFAULT 0, + success_files INTEGER NOT NULL DEFAULT 0, + failed_files INTEGER NOT NULL DEFAULT 0, + unsupported_files INTEGER NOT NULL DEFAULT 0, + uncertain_files INTEGER NOT NULL DEFAULT 0, + total_pages INTEGER NOT NULL DEFAULT 0, + work_dir VARCHAR(500) NOT NULL DEFAULT '', + error_message TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + started_at DATETIME NULL, + finished_at DATETIME NULL +); + +CREATE INDEX idx_ra_batch_conv_created +ON ra_file_summary_batch (conversation_id, created_at); + +CREATE INDEX idx_ra_batch_user_created +ON ra_file_summary_batch (user_id, created_at); + +CREATE INDEX idx_ra_batch_status +ON ra_file_summary_batch (status, created_at); +``` + +```sql +CREATE TABLE ra_file_summary_batch_attachment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id BIGINT NOT NULL, + attachment_id BIGINT NOT NULL, + source_role VARCHAR(20) NOT NULL DEFAULT 'multi_file', + created_at DATETIME NOT NULL, + UNIQUE (batch_id, attachment_id) +); + +CREATE INDEX idx_ra_batch_attachment_batch +ON ra_file_summary_batch_attachment (batch_id, created_at); + +CREATE INDEX idx_ra_batch_attachment_attachment +ON ra_file_summary_batch_attachment (attachment_id); +``` + +```sql +CREATE TABLE ra_file_summary_item ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id BIGINT NOT NULL, + file_index INTEGER NOT NULL, + directory_level VARCHAR(300) NOT NULL DEFAULT '', + file_name VARCHAR(255) NOT NULL, + file_type VARCHAR(20) NOT NULL, + relative_path VARCHAR(500) NOT NULL, + storage_path VARCHAR(500) NOT NULL, + page_count INTEGER NULL, + statistics_status VARCHAR(20) NOT NULL DEFAULT 'skipped', + retry_count INTEGER NOT NULL DEFAULT 0, + error_message TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE (batch_id, relative_path) +); + +CREATE INDEX idx_ra_item_batch_index +ON ra_file_summary_item (batch_id, file_index); + +CREATE INDEX idx_ra_item_batch_status +ON ra_file_summary_item (batch_id, statistics_status); + +CREATE INDEX idx_ra_item_batch_type +ON ra_file_summary_item (batch_id, file_type); +``` + +```sql +CREATE TABLE ra_workflow_node_run ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id BIGINT NOT NULL, + node_code VARCHAR(40) NOT NULL, + node_name VARCHAR(80) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + progress INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '', + started_at DATETIME NULL, + finished_at DATETIME NULL, + UNIQUE (batch_id, node_code) +); + +CREATE INDEX idx_ra_node_batch_status +ON ra_workflow_node_run (batch_id, status); +``` + +```sql +CREATE TABLE ra_workflow_event ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id BIGINT NOT NULL, + event_type VARCHAR(40) NOT NULL, + payload TEXT NOT NULL DEFAULT '{}', + created_at DATETIME NOT NULL +); + +CREATE INDEX idx_ra_event_batch_id +ON ra_workflow_event (batch_id, id); + +CREATE INDEX idx_ra_event_batch_created +ON ra_workflow_event (batch_id, created_at); +``` + +```sql +CREATE TABLE ra_exported_summary_file ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id BIGINT NOT NULL, + export_type VARCHAR(20) NOT NULL, + file_name VARCHAR(255) NOT NULL, + storage_path VARCHAR(500) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'success', + error_message TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL +); + +CREATE INDEX idx_ra_export_batch_type +ON ra_exported_summary_file (batch_id, export_type); + +CREATE INDEX idx_ra_export_batch_created +ON ra_exported_summary_file (batch_id, created_at); +``` + +--- + +## 八、Django ORM 落地注意事项 + +### 8.1 db_table + +每个模型通过 `class Meta: db_table = "ra_xxx"` 固定表名,避免 Django 默认生成较长表名。 + +### 8.2 JSONField + +`WorkflowEvent.payload` 使用 Django `models.JSONField(default=dict)`。SQLite 下实际以文本形式存储,Django 负责序列化与反序列化。 + +### 8.3 版本号生成 + +同一对话同名文件上传时: + +```text +version_no = max(existing version_no) + 1 +``` + +若新版本设为默认版本,需要将旧版本 `is_active` 更新为 false。 + +### 8.4 逻辑删除 + +附件删除时: + +```text +upload_status = deleted +is_active = false +``` + +历史批次仍可通过中间表追溯该附件。 + +### 8.5 批次选择附件 + +用户发送提示词触发工作流时: + +| 场景 | 处理 | +| --- | --- | +| 用户显式选择附件版本 | 使用所选 attachment_id | +| 用户未选择版本 | 使用当前对话下 is_active=true 且未删除的附件 | +| 存在多个同名 active 异常 | 取 created_at 最新,并记录待修复数据异常 | + +--- + +## 九、数据保留策略 + +| 数据 | Demo 策略 | 正式部署建议 | +| --- | --- | --- | +| 上传附件记录 | 永久保留 | 随会话归档周期清理 | +| 上传原始文件 | 永久保留 | 可按用户/项目配置保留期限 | +| 汇总批次 | 永久保留 | 保留用于审计追溯 | +| 文件明细 | 永久保留 | 保留用于历史报告复现 | +| 工作流事件 | 永久保留 | 可定期清理已完成批次的事件 | +| 导出文件 | 永久保留 | 可设置下载有效期或归档 | + +--- + +## 十、待确认事项 + +| 序号 | 问题 | 当前设计 | 状态 | +| --- | --- | --- | --- | +| 1 | 正式部署是否从 SQLite 迁移到 PostgreSQL/MySQL | 当前按 SQLite/Django ORM 设计,保留 ORM 兼容性 | 待后续确认 | +| 2 | 同名附件 active 是否允许多个 | 设计上不允许,代码更新时应关闭旧 active | 待开发实现 | +| 3 | 文件物理删除时机 | Demo 不物理删除 | 待后续确认 | + +--- + +## 十一、开发顺序建议 + +1. 在 `review_agent/models.py` 中新增上述 7 个模型。 +2. 为状态字段定义 Django `TextChoices`。 +3. 配置 `db_table`、`indexes`、`constraints`。 +4. 执行 `python manage.py makemigrations review_agent` 生成迁移。 +5. 执行 `python manage.py migrate` 验证 SQLite 可落表。 +6. 编写模型级测试,覆盖同名附件版本、批次附件绑定、唯一约束和权限查询。 diff --git a/docs/3.数据库设计/2.NMPA注册资料法规核查与整改闭环.md b/docs/3.数据库设计/2.NMPA注册资料法规核查与整改闭环.md new file mode 100644 index 0000000..788a1ed --- /dev/null +++ b/docs/3.数据库设计/2.NMPA注册资料法规核查与整改闭环.md @@ -0,0 +1,485 @@ +# NMPA 注册资料法规核查与整改闭环工作流数据库设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/2.NMPA注册资料法规核查与整改闭环.md | +| 功能设计文档 | docs/2.功能设计/2.NMPA注册资料法规核查与整改闭环.md | +| 数据库类型 | SQLite / Django ORM | +| 表名前缀 | ra_ | +| 设计日期 | 2026-06-06 | +| 设计版本 | V1.0 | + +--- + +## 一、设计原则 + +| 原则 | 说明 | +| --- | --- | +| 复用汇总批次 | 法规核查不重复保存文件清单,必须关联既有 `ra_file_summary_batch` | +| 独立核查批次 | 同一个文件汇总批次可以产生多次法规核查批次,适用条件变更时创建新批次 | +| 规则版本入库 | 结构化规则版本进入数据库,便于追溯规则文件、RAG 索引和启用状态 | +| RAG 不单独建表 | RAG 索引信息挂在规则版本和核查批次字段中,不新增索引表 | +| 枚举存值 | 数据库存英文枚举 value,前端或服务层映射为中文展示 | +| 关键字段独立 | 常用查询字段独立存储,其余过程上下文进入 JSON 或文件产物 | +| 大文本不入库 | 过程产物只在数据库保存路径、摘要和 hash,大文本内容写入文件 | +| 软删除优先 | 法规核查相关数据采用软删除/归档策略,便于审计和恢复 | +| 过程产物留底 | 条件确认、核查矩阵、风险清单、RAG 结果、通知记录、复核记录均需留底 | + +--- + +## 二、ER 图 + +```mermaid +erDiagram + AUTH_USER ||--o{ CONVERSATION : owns + CONVERSATION ||--o{ RA_FILE_SUMMARY_BATCH : has + RA_FILE_SUMMARY_BATCH ||--o{ RA_FILE_SUMMARY_ITEM : produces + RA_FILE_SUMMARY_BATCH ||--o{ RA_REGULATORY_REVIEW_BATCH : reviews + AUTH_USER ||--o{ RA_REGULATORY_REVIEW_BATCH : runs + AUTH_USER ||--o{ RA_REGULATORY_ISSUE : owns + RA_REGULATORY_RULE_VERSION ||--o{ RA_REGULATORY_REVIEW_BATCH : used_by + RA_REGULATORY_REVIEW_BATCH ||--o{ RA_REGULATORY_ISSUE : produces + RA_REGULATORY_REVIEW_BATCH ||--o{ RA_REGULATORY_ARTIFACT : keeps + RA_REGULATORY_REVIEW_BATCH ||--o{ RA_REGULATORY_NOTIFICATION_RECORD : sends + RA_REGULATORY_REVIEW_BATCH ||--o{ RA_EXPORTED_SUMMARY_FILE : exports + RA_REGULATORY_REVIEW_BATCH ||--o{ RA_WORKFLOW_NODE_RUN : tracks + RA_REGULATORY_REVIEW_BATCH ||--o{ RA_WORKFLOW_EVENT : emits +``` + +说明:`ra_workflow_node_run`、`ra_workflow_event` 在第一阶段设计中属于文件汇总批次节点记录表。法规核查工作流复用同一套事件机制,采用 `workflow_type`、`workflow_batch_id` 兼容多工作流;原 `batch_id` 保留用于兼容文件汇总旧逻辑。 + +--- + +## 三、表结构设计 + +### 3.1 ra_regulatory_rule_version + +法规结构化规则版本表。规则文件仍以 YAML/JSON 文件形式维护,数据库记录版本元数据、文件 hash、RAG 索引版本和启用状态。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| version | CharField(80) | varchar(80) | 是 | 规则版本,如 nmpa_ivd_2021_v1 | +| source_url | URLField(500) | varchar(500) | 是 | 法规来源 URL | +| source_path | CharField(500) | varchar(500) | 是 | 本地法规资料路径 | +| effective_date | DateField | date | 否 | 规则生效日期或公告日期 | +| rule_file_path | CharField(500) | varchar(500) | 是 | 结构化规则文件路径 | +| rule_file_hash | CharField(128) | varchar(128) | 是 | 规则文件 hash | +| rag_index_version | CharField(80) | varchar(80) | 否 | RAG 索引版本 | +| rag_index_path | CharField(500) | varchar(500) | 否 | RAG 索引存储路径 | +| is_active | BooleanField | bool | 是 | 是否当前启用版本 | +| created_by_id | ForeignKey(User) | bigint | 否 | 创建人 | +| activated_at | DateTimeField | datetime | 否 | 启用时间 | +| description | TextField | text | 否 | 版本说明 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | +| updated_at | DateTimeField | datetime | 是 | 更新时间 | +| is_deleted | BooleanField | bool | 是 | 软删除标记 | + +唯一约束: + +| 约束名 | 字段 | +| --- | --- | +| uq_ra_reg_rule_version | version | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_reg_rule_active | is_active, is_deleted | 查询当前启用规则 | +| idx_ra_reg_rule_effective | effective_date | 按生效日期追溯 | +| idx_ra_reg_rule_created | created_at | 查看规则版本历史 | + +--- + +### 3.2 ra_regulatory_review_batch + +法规核查批次表。一次法规核查工作流对应一条记录。同一个 `ra_file_summary_batch` 可关联多个法规核查批次,用于适用条件变更或重新核查。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| conversation_id | ForeignKey | bigint | 是 | 绑定对话 | +| user_id | ForeignKey | bigint | 是 | 发起用户 | +| file_summary_batch_id | ForeignKey | bigint | 是 | 关联文件汇总批次 | +| rule_version_id | ForeignKey | bigint | 否 | 使用的规则版本 | +| batch_no | CharField(64) | varchar(64) | 是 | 法规核查批次编号,唯一 | +| status | CharField(30) | varchar(30) | 是 | pending、running、waiting_user、success、failed、reference_only、partial_success、cancelled | +| product_category | CharField(80) | varchar(80) | 否 | 产品类别 | +| registration_type | CharField(80) | varchar(80) | 否 | 注册类型 | +| clinical_evaluation_path | CharField(120) | varchar(120) | 否 | 临床评价路径 | +| product_name | CharField(200) | varchar(200) | 否 | 产品名称 | +| model_specification | CharField(200) | varchar(200) | 否 | 型号规格 | +| intended_use | TextField | text | 否 | 预期用途 | +| condition_json | JSONField | text/json | 否 | 其他适用条件、用户确认记录和抽取置信度 | +| rule_version_value | CharField(80) | varchar(80) | 否 | 冗余记录规则版本值,便于历史追溯 | +| rule_source_url | URLField(500) | varchar(500) | 否 | 冗余记录法规来源 URL | +| rule_source_path | CharField(500) | varchar(500) | 否 | 冗余记录本地法规资料路径 | +| rag_index_version | CharField(80) | varchar(80) | 否 | 本次使用的 RAG 索引版本 | +| risk_summary_json | JSONField | text/json | 否 | 风险数量摘要 | +| artifact_root | CharField(500) | varchar(500) | 否 | 本批次过程产物根目录 | +| error_message | TextField | text | 否 | 批次异常说明 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | +| started_at | DateTimeField | datetime | 否 | 开始时间 | +| finished_at | DateTimeField | datetime | 否 | 完成时间 | +| archived_at | DateTimeField | datetime | 否 | 归档时间 | +| is_deleted | BooleanField | bool | 是 | 软删除标记 | + +唯一约束: + +| 约束名 | 字段 | +| --- | --- | +| uq_ra_reg_batch_no | batch_no | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_reg_batch_conv_status | conversation_id, status | 查询对话下法规核查批次状态 | +| idx_ra_reg_batch_summary | file_summary_batch_id | 根据文件汇总批次查询法规核查历史 | +| idx_ra_reg_batch_created | created_at | 按创建时间查询 | +| idx_ra_reg_batch_rule | rule_version_value | 规则版本追溯 | +| idx_ra_reg_batch_user_created | user_id, created_at | 查询用户发起记录 | + +--- + +### 3.3 ra_regulatory_issue + +法规核查问题表,记录完整性、章节结构、一致性、通知、复核等业务问题及整改状态。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| batch_id | ForeignKey | bigint | 是 | 所属法规核查批次 | +| owner_id | ForeignKey(User) | bigint | 否 | 责任人,默认上传人 | +| issue_code | CharField(100) | varchar(100) | 是 | 问题编码 | +| issue_type | CharField(40) | varchar(40) | 是 | completeness、structure、consistency、notification、review | +| risk_level | CharField(20) | varchar(20) | 是 | blocking、high、medium、low、info | +| status | CharField(30) | varchar(30) | 是 | pending_confirm、pending_fix、fixed、review_passed、review_failed、closed | +| title | CharField(255) | varchar(255) | 是 | 问题标题 | +| description | TextField | text | 否 | 问题描述 | +| rule_id | CharField(120) | varchar(120) | 否 | 命中的规则 ID | +| regulation_basis | TextField | text | 否 | 法规依据或规则依据 | +| file_item_id | ForeignKey(FileSummaryItem) | bigint | 否 | 关联文件明细,可为空 | +| file_path | CharField(500) | varchar(500) | 否 | 常用证据文件路径 | +| page_no | PositiveIntegerField | integer | 否 | 常用证据页码 | +| field_name | CharField(120) | varchar(120) | 否 | 一致性或字段问题名称 | +| evidence_json | JSONField | text/json | 否 | 证据详情,如文本片段、多个来源值、RAG 引用等 | +| suggestion | TextField | text | 否 | 整改建议 | +| source_node | CharField(60) | varchar(60) | 否 | 产生问题的工作流节点 | +| confirmed_by_id | ForeignKey(User) | bigint | 否 | 确认人 | +| confirmed_at | DateTimeField | datetime | 否 | 确认时间 | +| closed_by_id | ForeignKey(User) | bigint | 否 | 关闭人 | +| closed_at | DateTimeField | datetime | 否 | 关闭时间 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | +| updated_at | DateTimeField | datetime | 是 | 更新时间 | +| is_deleted | BooleanField | bool | 是 | 软删除标记 | + +唯一约束: + +| 约束名 | 字段 | +| --- | --- | +| uq_ra_reg_issue_batch_code | batch_id, issue_code | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_reg_issue_batch | batch_id, created_at | 查询批次问题 | +| idx_ra_reg_issue_risk_status | risk_level, status | 风险列表和整改状态筛选 | +| idx_ra_reg_issue_owner_status | owner_id, status | 责任人待办 | +| idx_ra_reg_issue_rule | rule_id | 规则问题追溯 | +| idx_ra_reg_issue_file | file_item_id | 关联文件问题 | +| idx_ra_reg_issue_field | field_name | 字段一致性问题查询 | + +--- + +### 3.4 ra_regulatory_artifact + +法规核查过程产物表。只保存文件元数据,不保存大文本全文。文件内容写入受控存储目录,`file_hash` 必填。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| batch_id | ForeignKey | bigint | 是 | 所属法规核查批次 | +| artifact_type | CharField(60) | varchar(60) | 是 | condition_record、rule_matrix、risk_list、text_extract_json、rag_result_json、notification_record、review_record | +| file_format | CharField(20) | varchar(20) | 是 | markdown、excel、json | +| file_name | CharField(255) | varchar(255) | 是 | 文件名 | +| storage_path | CharField(500) | varchar(500) | 是 | 存储路径 | +| file_size | BigIntegerField | bigint | 是 | 文件大小 | +| file_hash | CharField(128) | varchar(128) | 是 | 文件 hash,用于校验留底文件未被篡改 | +| summary | TextField | text | 否 | 产物摘要 | +| created_by_node | CharField(60) | varchar(60) | 否 | 产生该产物的工作流节点 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | +| is_deleted | BooleanField | bool | 是 | 软删除标记 | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_reg_artifact_batch_type | batch_id, artifact_type | 查询批次过程产物 | +| idx_ra_reg_artifact_format | file_format | 按格式查询 | +| idx_ra_reg_artifact_created | created_at | 按时间追溯 | + +--- + +### 3.5 ra_regulatory_notification_record + +法规核查通知记录表,记录飞书 CLI 发送结果。飞书失败不阻断工作流,但需要留痕。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| batch_id | ForeignKey | bigint | 是 | 所属法规核查批次 | +| recipient_id | ForeignKey(User) | bigint | 是 | 通知对象 | +| channel | CharField(30) | varchar(30) | 是 | feishu_cli、feishu_api、mock | +| risk_levels | JSONField | text/json | 是 | 本次通知包含的风险等级 | +| issue_ids | JSONField | text/json | 是 | 本次通知关联的问题 ID 列表 | +| message_summary | TextField | text | 是 | 通知内容摘要 | +| send_status | CharField(20) | varchar(20) | 是 | pending、success、failed | +| retry_count | PositiveIntegerField | integer | 是 | 已重试次数,最多 3 次 | +| external_message_id | CharField(120) | varchar(120) | 否 | 飞书外部消息 ID | +| error_message | TextField | text | 否 | 失败原因 | +| sent_at | DateTimeField | datetime | 否 | 发送成功时间 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | +| updated_at | DateTimeField | datetime | 是 | 更新时间 | +| is_deleted | BooleanField | bool | 是 | 软删除标记 | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_reg_notify_batch | batch_id, created_at | 查询批次通知记录 | +| idx_ra_reg_notify_recipient | recipient_id, send_status | 查询用户通知状态 | +| idx_ra_reg_notify_status | send_status, retry_count | 查询待重试通知 | + +--- + +## 四、枚举设计 + +### 4.1 RegulatoryReviewBatch.status + +| value | 中文展示 | 说明 | +| --- | --- | --- | +| pending | 待执行 | 已创建,等待执行 | +| running | 执行中 | 工作流正在执行 | +| waiting_user | 等待用户 | 等待用户确认适用条件或关闭复核 | +| success | 已完成 | 核查完成且无关键失败 | +| failed | 失败 | 关键节点失败,无法输出有效结果 | +| reference_only | 仅供参考 | 规则文件加载失败,降级为 RAG 辅助核查 | +| partial_success | 部分完成 | 部分节点或通知失败,但已输出主要结果 | +| cancelled | 已取消 | 用户或系统取消执行 | + +### 4.2 RegulatoryIssue.status + +| value | 中文展示 | 说明 | +| --- | --- | --- | +| pending_confirm | 待确认 | 条件性问题或低置信度问题等待人工确认 | +| pending_fix | 待处理 | 已确认需要补充或整改 | +| fixed | 已补充 | 用户已上传补充资料或声明已处理 | +| review_passed | 复核通过 | 系统复核通过,关闭前仍需人工确认 | +| review_failed | 复核不通过 | 系统复核后问题仍存在 | +| closed | 已关闭 | 用户确认问题解决并关闭 | + +### 4.3 RegulatoryIssue.risk_level + +| value | 中文展示 | 说明 | +| --- | --- | --- | +| blocking | 阻断项 | 直接影响资料能否进入有效申报或审核 | +| high | 高风险 | 可能导致注册审评补正或重大整改 | +| medium | 中风险 | 需要补充说明或修改 | +| low | 低风险 | 建议修正但影响较小 | +| info | 提示项 | 系统无法充分判断或建议人工关注 | + +### 4.4 其他枚举 + +| 字段 | value | +| --- | --- | +| issue_type | completeness、structure、consistency、notification、review | +| artifact_type | condition_record、rule_matrix、risk_list、text_extract_json、rag_result_json、notification_record、review_record | +| file_format | markdown、excel、json | +| send_status | pending、success、failed | +| channel | feishu_cli、feishu_api、mock | + +--- + +## 五、软删除与归档策略 + +| 对象 | 策略 | +| --- | --- | +| RegulatoryRuleVersion | 使用 `is_deleted` 软删除;已被批次引用的版本不允许物理删除 | +| RegulatoryReviewBatch | 使用 `is_deleted` 和 `archived_at` 归档;归档后默认不在对话主列表展示 | +| RegulatoryIssue | 使用 `is_deleted` 软删除;删除时保留批次摘要和过程产物 | +| RegulatoryArtifact | 使用 `is_deleted` 软删除;正式环境可配合对象存储生命周期归档 | +| RegulatoryNotificationRecord | 使用 `is_deleted` 软删除;保留通知失败原因和重试次数 | + +删除 Conversation 时,本期不建议物理级联法规核查数据。应先标记相关批次归档或删除,再由后台清理任务处理文件和产物。 + +--- + +## 六、过程产物存储设计 + +### 6.1 存储目录 + +法规核查过程产物使用独立目录,按用户、对话、法规核查批次隔离: + +```text +media/regulatory_review/{user_id}/{conversation_id}/{batch_id}/ +``` + +示例: + +```text +media/regulatory_review/12/1001/2001/ + condition_record.md + condition_record.json + rule_matrix.xlsx + risk_list.md + risk_list.json + text_extract.json + rag_result.json + notification_record.md + review_record.json +``` + +### 6.2 文件 hash + +`ra_regulatory_artifact.file_hash` 必填。建议使用 SHA-256。 + +| 场景 | 处理 | +| --- | --- | +| 文件生成成功 | 计算 hash 后写入记录 | +| hash 计算失败 | 产物生成视为失败,节点进入 partial_success 或 failed | +| 下载文件 | 可选重新计算 hash 校验 | + +--- + +## 七、JSON 字段结构建议 + +### 7.1 condition_json + +```json +{ + "extracted": { + "product_category": {"value": "in_vitro_diagnostic", "confidence": 0.92}, + "registration_type": {"value": "initial_registration", "confidence": 0.76} + }, + "confirmed": { + "confirmed_by": 1, + "confirmed_at": "2026-06-06T00:00:00+08:00", + "source": "dialog_choice" + }, + "raw_user_input": "按体外诊断试剂首次注册处理" +} +``` + +### 7.2 risk_summary_json + +```json +{ + "blocking": 2, + "high": 1, + "medium": 3, + "low": 4, + "info": 2, + "notified": { + "feishu": 6 + } +} +``` + +### 7.3 evidence_json + +```json +{ + "matched_rule": { + "rule_id": "ivd_registration_test_report", + "rule_title": "注册检验报告" + }, + "matched_files": [ + { + "file_item_id": 33, + "relative_path": "注册检验/检验报告.pdf", + "matched_by": "directory_keyword" + } + ], + "rag_citations": [ + { + "source_file": "体外诊断试剂注册申报资料要求及说明.doc", + "section_title": "注册申报资料要求", + "snippet": "..." + } + ] +} +``` + +--- + +## 八、与现有表的改造建议 + +### 8.1 ra_workflow_node_run + +第一阶段设计中该表通过 `batch_id` 直接关联文件汇总批次。法规核查复用同一套工作流状态机制,采用通用工作流引用: + +| 字段 | 说明 | +| --- | --- | +| workflow_type | 新增,用于区分 file_summary 和 regulatory_review | +| workflow_batch_id | 新增,记录对应工作流批次 ID | +| batch_id | 保留,兼容文件汇总旧逻辑 | + +### 8.2 ra_workflow_event + +同样增加 `workflow_type`、`workflow_batch_id`,使 SSE 能同时服务文件汇总和法规核查卡片。 + +### 8.3 ra_exported_summary_file + +最终法规核查报告复用导出文件表。现有 `batch_id` 关联文件汇总批次,需要通用化: + +| 字段 | 说明 | +| --- | --- | +| workflow_type | 新增,用于区分 file_summary 和 regulatory_review | +| workflow_batch_id | 新增,记录对应工作流批次 ID | +| batch_id | 保留,兼容文件汇总旧逻辑 | +| export_category | 新增,用于区分 summary_report、risk_report、excel_list、json_package | + +最终法规核查报告进入 `ExportedSummaryFile`,过程产物进入 `RegulatoryArtifact`。 + +--- + +## 九、Django Model 命名建议 + +| 表名 | Model 名称 | +| --- | --- | +| ra_regulatory_rule_version | RegulatoryRuleVersion | +| ra_regulatory_review_batch | RegulatoryReviewBatch | +| ra_regulatory_issue | RegulatoryIssue | +| ra_regulatory_artifact | RegulatoryArtifact | +| ra_regulatory_notification_record | RegulatoryNotificationRecord | + +--- + +## 十、验收检查点 + +| 序号 | 检查项 | 验收标准 | +| --- | --- | --- | +| 1 | 规则版本可追溯 | 每个法规核查批次能查到 rule_version、source_path、rule_file_hash 和 rag_index_version | +| 2 | 批次可多次核查 | 同一个 FileSummaryBatch 可创建多个 RegulatoryReviewBatch | +| 3 | 软删除可用 | 归档或删除法规核查批次后,默认列表不展示但历史可追溯 | +| 4 | 问题可筛选 | 可按 risk_level、status、owner 查询待处理问题 | +| 5 | 证据可追溯 | Issue 可查到 file_path、page_no、field_name 和 evidence_json | +| 6 | 产物可校验 | 每个 RegulatoryArtifact 都有 file_hash | +| 7 | 飞书可重试 | NotificationRecord 可记录 retry_count、send_status 和失败原因 | +| 8 | 权限可追溯 | 所有法规核查数据可通过 batch -> conversation -> user 校验访问权限 | + +--- + +## 十一、后续实现注意事项 + +| 序号 | 问题 | 当前建议 | +| --- | --- | --- | +| 1 | WorkflowNodeRun/Event 通用化 | 已确定新增 workflow_type 和 workflow_batch_id,保留 batch_id 兼容文件汇总 | +| 2 | ExportedSummaryFile 通用化 | 已确定新增 workflow_type、workflow_batch_id 和 export_category | +| 3 | RegulatoryArtifact 下载接口 | 按 batch -> conversation -> user 校验权限 | +| 4 | 飞书用户映射 | 暂通过 User 扩展字段或配置表映射飞书 CLI 可识别账号 | +| 5 | 规则文件 hash 计算时机 | 规则导入或激活时计算并写入 RegulatoryRuleVersion | diff --git a/docs/3.数据库设计/3.产品关键信息提取与申报文件自动填表.md b/docs/3.数据库设计/3.产品关键信息提取与申报文件自动填表.md new file mode 100644 index 0000000..f13e417 --- /dev/null +++ b/docs/3.数据库设计/3.产品关键信息提取与申报文件自动填表.md @@ -0,0 +1,433 @@ +# 产品关键信息提取与申报文件自动填表数据库设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/3.产品关键信息提取与申报文件自动填表.md | +| 功能设计文档 | docs/2.功能设计/3.产品关键信息提取与申报文件自动填表.md | +| 数据库类型 | SQLite / Django ORM | +| 表名前缀 | ra_ | +| 设计日期 | 2026-06-07 | +| 设计版本 | V1.0 | + +--- + +## 一、设计原则 + +| 原则 | 说明 | +| --- | --- | +| 独立填表批次 | 自动填表作为独立工作流,使用独立批次表,不强绑法规核查批次 | +| 复用文件来源 | 填表批次必须关联一个成功的 `FileSummaryBatch`,不重复保存文件清单 | +| 可选复用法规条件 | 如当前对话已有已确认法规核查批次,可通过可空外键复用注册类型等条件 | +| 导出记录复用 | Word、Excel、JSON、PDF 等下载文件继续进入 `ExportedSummaryFile` | +| 过程产物独立 | 自动填表过程产物单独建表,避免和法规核查 `RegulatoryArtifact` 混用 | +| 通知记录独立 | 自动填表飞书通知单独建表,字段风格与法规通知记录保持一致 | +| 大文本不入库 | 字段抽取 JSON、追溯清单和模板副本保存为文件,数据库仅保存路径、hash 和摘要 | +| 字段明细暂不入库 | 本期不新增字段级明细表;字段结果保存在 JSON/Excel 产物与批次摘要中 | +| SQLite 兼容 | 字段类型、索引和约束优先保证当前 SQLite + Django ORM 可运行 | + +--- + +## 二、ER 图 + +```mermaid +erDiagram + AUTH_USER ||--o{ CONVERSATION : owns + CONVERSATION ||--o{ RA_FILE_SUMMARY_BATCH : has + RA_FILE_SUMMARY_BATCH ||--o{ RA_FILE_SUMMARY_ITEM : produces + RA_FILE_SUMMARY_BATCH ||--o{ RA_APPLICATION_FORM_FILL_BATCH : feeds + RA_REGULATORY_REVIEW_BATCH ||--o{ RA_APPLICATION_FORM_FILL_BATCH : optionally_confirms + AUTH_USER ||--o{ RA_APPLICATION_FORM_FILL_BATCH : runs + CONVERSATION ||--o{ RA_APPLICATION_FORM_FILL_BATCH : has + MESSAGE ||--o{ RA_APPLICATION_FORM_FILL_BATCH : triggers + RA_APPLICATION_FORM_FILL_BATCH ||--o{ RA_APPLICATION_FORM_FILL_ARTIFACT : keeps + RA_APPLICATION_FORM_FILL_BATCH ||--o{ RA_APPLICATION_FORM_FILL_NOTIFICATION_RECORD : sends + RA_APPLICATION_FORM_FILL_BATCH ||--o{ RA_EXPORTED_SUMMARY_FILE : exports + RA_APPLICATION_FORM_FILL_BATCH ||--o{ RA_WORKFLOW_NODE_RUN : tracks + RA_APPLICATION_FORM_FILL_BATCH ||--o{ RA_WORKFLOW_EVENT : emits +``` + +说明:`ra_workflow_node_run`、`ra_workflow_event`、`ra_exported_summary_file` 已在第二批中被通用化,通过 `workflow_type` 与 `workflow_batch_id` 支持多工作流。本功能使用 `workflow_type=application_form_fill`。 + +--- + +## 三、表结构设计 + +### 3.1 ra_application_form_fill_batch + +一次自动填表工作流批次。该表记录本次触发来源、选择模板、输出类型、注册类型、产品名称、冲突摘要、工作目录和状态。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| conversation_id | ForeignKey | bigint | 是 | 绑定对话 | +| user_id | ForeignKey | bigint | 是 | 发起用户 | +| trigger_message_id | ForeignKey | bigint | 否 | 触发填表工作流的用户消息 | +| source_summary_batch_id | ForeignKey | bigint | 是 | 文件来源汇总批次 | +| source_regulatory_batch_id | ForeignKey | bigint | 否 | 可选,复用已确认法规核查批次条件 | +| batch_no | CharField(64) | varchar(64) | 是 | 填表批次编号,唯一 | +| status | CharField(30) | varchar(30) | 是 | pending、running、waiting_user、success、partial_success、failed、cancelled | +| requested_templates | JSONField | text/json | 是 | 用户指定模板编码列表;未指定为空数组 | +| selected_templates | JSONField | text/json | 是 | 系统实际选择模板编码列表 | +| output_types | JSONField | text/json | 是 | 请求输出类型,如 word、excel、json、pdf | +| registration_type | CharField(80) | varchar(80) | 否 | 识别出的注册类型 | +| registration_type_source | CharField(40) | varchar(40) | 否 | user_message、regulatory_batch、file_extract、unknown | +| product_name | CharField(200) | varchar(200) | 否 | 产品名称 | +| conflict_summary | JSONField | text/json | 是 | 冲突字段摘要 | +| risk_notes | JSONField | text/json | 是 | 不适用模板、低置信度、PDF 待生成等提示 | +| template_config_version | CharField(80) | varchar(80) | 否 | 模板配置版本 | +| template_config_hash | CharField(128) | varchar(128) | 否 | 模板配置文件 hash | +| work_dir | CharField(500) | varchar(500) | 否 | 批次工作目录 | +| error_message | TextField | text | 否 | 批次异常说明 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | +| started_at | DateTimeField | datetime | 否 | 开始时间 | +| finished_at | DateTimeField | datetime | 否 | 完成时间 | +| archived_at | DateTimeField | datetime | 否 | 归档时间 | +| is_deleted | BooleanField | bool | 是 | 软删除标记 | + +唯一约束: + +| 约束名 | 字段 | +| --- | --- | +| uq_ra_aff_batch_no | batch_no | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_aff_batch_conv_status | conversation_id, status | 查询对话下填表批次状态 | +| idx_ra_aff_batch_summary | source_summary_batch_id | 根据文件汇总批次查询填表历史 | +| idx_ra_aff_batch_regulatory | source_regulatory_batch_id | 根据法规核查批次查询关联填表历史 | +| idx_ra_aff_batch_user_created | user_id, created_at | 查询用户发起记录 | +| idx_ra_aff_batch_created | created_at | 按创建时间查询 | + +--- + +### 3.2 ra_application_form_fill_artifact + +自动填表过程产物表。仅保存文件元数据,不保存字段抽取大 JSON 的全文。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| batch_id | ForeignKey | bigint | 是 | 所属自动填表批次 | +| artifact_type | CharField(60) | varchar(60) | 是 | template_copy、field_extract_result、merged_fields、traceability、filled_template、notification_record | +| file_format | CharField(20) | varchar(20) | 是 | json、excel、docx、pdf、markdown | +| name | CharField(160) | varchar(160) | 是 | 产物名称 | +| file_name | CharField(255) | varchar(255) | 是 | 文件名 | +| storage_path | CharField(500) | varchar(500) | 是 | 存储路径 | +| file_size | BigIntegerField | bigint | 是 | 文件大小 | +| content_hash | CharField(128) | varchar(128) | 是 | 文件 SHA-256 hash | +| metadata | JSONField | text/json | 是 | 模板编码、输出类型、生成状态、错误摘要等 | +| created_by_node | CharField(60) | varchar(60) | 否 | 产生该产物的节点 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | +| is_deleted | BooleanField | bool | 是 | 软删除标记 | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_aff_artifact_batch_type | batch_id, artifact_type | 查询批次过程产物 | +| idx_ra_aff_artifact_format | file_format | 按文件格式查询 | +| idx_ra_aff_artifact_created | created_at | 按时间追溯 | + +--- + +### 3.3 ra_application_form_fill_notification_record + +自动填表飞书通知记录表。通知失败不阻断文件下载,但需要留痕和支持后续重试。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| batch_id | ForeignKey | bigint | 是 | 所属自动填表批次 | +| recipient_id | ForeignKey(User) | bigint | 是 | 通知对象,默认上传人/发起人 | +| channel | CharField(30) | varchar(30) | 是 | feishu_cli、feishu_api、mock | +| template_codes | JSONField | text/json | 是 | 本次通知涉及模板 | +| export_ids | JSONField | text/json | 是 | 本次通知关联导出文件 ID | +| message_summary | TextField | text | 是 | 通知摘要 | +| send_status | CharField(20) | varchar(20) | 是 | pending、success、failed | +| retry_count | PositiveIntegerField | integer | 是 | 已重试次数 | +| external_message_id | CharField(120) | varchar(120) | 否 | 飞书外部消息 ID | +| error_message | TextField | text | 否 | 失败原因 | +| sent_at | DateTimeField | datetime | 否 | 发送成功时间 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | +| updated_at | DateTimeField | datetime | 是 | 更新时间 | +| is_deleted | BooleanField | bool | 是 | 软删除标记 | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_aff_notify_batch | batch_id, created_at | 查询批次通知记录 | +| idx_ra_aff_notify_recipient | recipient_id, send_status | 查询用户通知状态 | +| idx_ra_aff_notify_status | send_status, retry_count | 查询待重试通知 | + +--- + +## 四、既有表扩展 + +### 4.1 ra_exported_summary_file + +继续复用导出文件表,需扩展导出类型。 + +| 字段/枚举 | 处理 | +| --- | --- | +| export_type | 增加 `word`、`pdf` | +| workflow_type | 使用 `application_form_fill` | +| workflow_batch_id | 记录 `ApplicationFormFillBatch.id` | +| export_category | 使用 `filled_template`、`traceability`、`extract_result` | + +导出类型枚举: + +| value | 中文展示 | 说明 | +| --- | --- | --- | +| markdown | Markdown | 既有报告 | +| excel | Excel | 追溯清单 | +| json | JSON | 字段抽取结果包 | +| word | Word | 填好的 Word 模板 | +| pdf | PDF | Word 转换后的 PDF,P1 预留 | + +### 4.2 ra_workflow_node_run + +本功能使用通用工作流字段: + +| 字段 | 值 | +| --- | --- | +| workflow_type | application_form_fill | +| workflow_batch_id | ApplicationFormFillBatch.id | +| node_group | form_fill | +| batch_id | 可为空或兼容性填充 source_summary_batch_id | + +### 4.3 ra_workflow_event + +本功能事件写入: + +| 字段 | 值 | +| --- | --- | +| workflow_type | application_form_fill | +| workflow_batch_id | ApplicationFormFillBatch.id | +| conversation_id | 当前对话 ID | +| payload | 节点状态、模板列表、冲突数量、导出文件等 | + +--- + +## 五、枚举设计 + +### 5.1 ApplicationFormFillBatch.status + +| value | 中文展示 | 说明 | +| --- | --- | --- | +| pending | 待执行 | 批次已创建,等待执行 | +| running | 执行中 | 工作流正在执行 | +| waiting_user | 等待用户 | 缺少文件汇总批次或关键条件 | +| success | 成功 | Word 和必要追溯产物生成成功 | +| partial_success | 部分成功 | 部分模板、PDF、追溯清单或通知失败 | +| failed | 失败 | 所有目标 Word 模板均生成失败 | +| cancelled | 已取消 | 用户或系统取消执行 | + +### 5.2 artifact_type + +| value | 说明 | +| --- | --- | +| template_copy | 模板副本 | +| field_extract_result | 规则/正则与 LLM 抽取原始结果 | +| merged_fields | 合并后的最终字段和冲突 | +| traceability | 字段来源追溯清单 | +| filled_template | 已填写模板 | +| notification_record | 通知记录产物 | + +### 5.3 registration_type_source + +| value | 说明 | +| --- | --- | +| user_message | 用户话语明确指定 | +| regulatory_batch | 复用已确认法规核查条件 | +| file_extract | 从文件内容抽取 | +| unknown | 未识别 | + +### 5.4 通知枚举 + +| 字段 | value | +| --- | --- | +| channel | feishu_cli、feishu_api、mock | +| send_status | pending、success、failed | + +--- + +## 六、JSON 字段结构建议 + +### 6.1 requested_templates / selected_templates + +```json +["registration_certificate", "essential_principles"] +``` + +### 6.2 output_types + +```json +["word", "excel", "json"] +``` + +PDF 作为 P1 预留,可在后续加入: + +```json +["word", "pdf", "excel", "json"] +``` + +### 6.3 conflict_summary + +```json +[ + { + "field_key": "storage_condition", + "field_label": "产品储存条件及有效期", + "selected_value": "2-8℃保存,有效期12个月", + "selected_source": "说明书.docx", + "conflict_values": [ + { + "value": "-20℃保存", + "source_file": "产品技术要求.docx", + "evidence": "储存条件:-20℃保存" + } + ], + "handling": "说明书优先,模板内黄底红字高亮" + } +] +``` + +### 6.4 risk_notes + +```json +[ + { + "type": "template_registration_mismatch", + "message": "用户指定变更注册(备案)文件,但系统识别注册类型为首次注册,需人工确认。" + }, + { + "type": "pdf_pending", + "message": "PDF 转换为后续增强项,本次优先生成 Word。" + } +] +``` + +### 6.5 artifact.metadata + +```json +{ + "template_code": "registration_certificate", + "output_type": "word", + "node_code": "word_fill", + "status": "success", + "conflict_count": 2 +} +``` + +--- + +## 七、存储路径设计 + +自动填表工作目录按用户、对话和批次隔离: + +```text +media/application_form_fill/{user_id}/{conversation_id}/{batch_no}/ +``` + +目录结构: + +```text +media/application_form_fill/12/1001/AFF-20260607153000-a1b2c3/ + templates/ + registration_certificate.source.docx + essential_principles.source.docx + filled/ + AFF-20260607153000-a1b2c3-甲胎蛋白检测试剂盒-注册证格式.docx + exports/ + AFF-20260607153000-a1b2c3-甲胎蛋白检测试剂盒-字段来源追溯清单.xlsx + field_extract_result.json + merged_fields.json + notifications/ + notification_record.json +``` + +所有产物写入 `ApplicationFormFillArtifact` 时必须记录 SHA-256 hash。 + +--- + +## 八、权限与查询规则 + +### 8.1 批次访问权限 + +```text +ApplicationFormFillBatch -> conversation -> user +必须等于当前 request.user +``` + +### 8.2 导出下载权限 + +```text +ExportedSummaryFile.workflow_type == application_form_fill +-> workflow_batch_id +-> ApplicationFormFillBatch.conversation.user +``` + +若 `workflow_type=file_summary` 或 `regulatory_review`,仍按既有逻辑校验。 + +### 8.3 文件读取权限 + +自动填表只能读取 `source_summary_batch.items` 对应的文件,不允许从其他对话或其他批次随意读取文件。 + +--- + +## 九、字段级数据库表暂缓说明 + +本期不新增 `ApplicationFormFillField` 字段级明细表。原因: + +| 原因 | 说明 | +| --- | --- | +| Demo 主链路更轻 | 字段结果以 JSON 和 Excel 追溯清单即可满足下载复核 | +| 避免过早建模 | 字段结构依赖模板配置和后续人工修改交互,暂不固化表结构 | +| 查询需求有限 | 本期主要按批次下载文件,不做字段级统计和在线编辑 | + +后续如需要在线确认、人工修改、字段级审计或批量统计,再新增字段级表。该事项写入 `docs/6.待办计划/第二阶段暂缓事项.md`。 + +--- + +## 十、Django Model 命名建议 + +| 表名 | Model 名称 | +| --- | --- | +| ra_application_form_fill_batch | ApplicationFormFillBatch | +| ra_application_form_fill_artifact | ApplicationFormFillArtifact | +| ra_application_form_fill_notification_record | ApplicationFormFillNotificationRecord | + +建议模型仍集中放在 `review_agent/models.py`,与前两批现有模型保持一致;业务逻辑放在 `review_agent/application_form_fill/`。 + +--- + +## 十一、验收检查点 + +| 序号 | 检查项 | 验收标准 | +| --- | --- | --- | +| 1 | 独立批次 | 触发填表后生成 `ApplicationFormFillBatch` | +| 2 | 文件来源 | 每个填表批次都关联一个成功的 `FileSummaryBatch` | +| 3 | 可选法规条件 | 如有关联法规核查批次,可记录 `source_regulatory_batch` | +| 4 | 过程产物 | 字段抽取 JSON、合并结果、追溯清单、模板副本均可留底 | +| 5 | 导出复用 | 填好的 Word 和追溯清单进入 `ExportedSummaryFile` | +| 6 | 导出类型 | `ExportedSummaryFile.ExportType` 支持 `word`、`pdf` | +| 7 | 通知记录 | 飞书通知记录能保存状态、重试次数、失败原因 | +| 8 | 权限隔离 | A 对话的填表批次和导出文件不能被 B 对话访问 | +| 9 | 字段表暂缓 | 字段级结果不入库,但能从 JSON/Excel 追溯产物复核 | + +--- + +## 十二、开发顺序建议 + +1. 扩展 `ExportedSummaryFile.ExportType`,增加 `word`、`pdf`。 +2. 新增 `ApplicationFormFillBatch`、`ApplicationFormFillArtifact`、`ApplicationFormFillNotificationRecord`。 +3. 为新增状态字段定义 Django `TextChoices`。 +4. 配置表名、索引和唯一约束。 +5. 执行 `python manage.py makemigrations review_agent` 和 `python manage.py migrate`。 +6. 编写模型测试,覆盖批次创建、产物 hash、通知重试字段、导出权限查询。 +7. 将字段级数据库表和 PDF 转换能力写入待办计划。 diff --git a/docs/3.数据库设计/4.飞书通知与问答接入.md b/docs/3.数据库设计/4.飞书通知与问答接入.md new file mode 100644 index 0000000..55ad261 --- /dev/null +++ b/docs/3.数据库设计/4.飞书通知与问答接入.md @@ -0,0 +1,302 @@ +# 飞书通知与问答接入数据库设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/4.飞书通知与问答接入.md | +| 功能设计文档 | docs/2.功能设计/4.飞书通知与问答接入.md | +| 数据库类型 | SQLite / Django ORM | +| 表名前缀 | ra_ | +| 设计日期 | 2026-06-07 | +| 设计版本 | V1.0 | + +--- + +## 一、设计原则 + +| 原则 | 说明 | +| --- | --- | +| 统一通知抽象 | 三个工作流共用统一通知服务和通用通知记录,减少重复实现 | +| 兼容现有表 | 现有法规通知、填表通知可保留;新增通用表作为后续统一入口 | +| 可判重 | 通知记录必须支持同一批次、同一流程、同一状态只发送一次 | +| 摘要入库 | 只保存发送摘要、状态、错误,不保存完整富文本 payload | +| 映射可维护 | 系统用户与飞书用户映射独立建表,通过 Django Admin 维护 | +| 问答可扩展 | 预留问答日志表,首期可不接事件回调 | +| SQLite 兼容 | 使用 Django ORM 常规字段,避免数据库特有能力 | + +--- + +## 二、ER 图 + +```mermaid +erDiagram + AUTH_USER ||--o{ RA_FEISHU_USER_MAPPING : maps + AUTH_USER ||--o{ RA_WORKFLOW_NOTIFICATION_RECORD : triggers + RA_FEISHU_USER_MAPPING ||--o{ RA_WORKFLOW_NOTIFICATION_RECORD : resolves + AUTH_USER ||--o{ RA_FEISHU_QUESTION_LOG : asks + + RA_WORKFLOW_NOTIFICATION_RECORD { + bigint id + string workflow_type + bigint workflow_batch_id + string workflow_status + string dedupe_key + string channel + string target + string send_status + } + + RA_FEISHU_USER_MAPPING { + bigint id + bigint system_user_id + string feishu_open_id + string feishu_user_id + string feishu_mobile + boolean is_active + } + + RA_FEISHU_QUESTION_LOG { + bigint id + bigint system_user_id + string feishu_open_id + string intent + string query_object + string status + } +``` + +--- + +## 三、表结构设计 + +### 3.1 ra_feishu_user_mapping + +系统用户与飞书用户标识映射表。首期通知发送给环境变量中配置的指定个人账号,本表通过 Django Admin 手工维护,用于后续按发起人私聊通知和飞书私聊问答身份识别。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| system_user_id | ForeignKey | bigint | 是 | 关联 Django 用户 | +| feishu_display_name | CharField(120) | varchar(120) | 否 | 飞书展示名,便于后台识别 | +| feishu_open_id | CharField(120) | varchar(120) | 否 | 飞书 open_id,优先用于 @ | +| feishu_user_id | CharField(120) | varchar(120) | 否 | 飞书 user_id,第二优先级 | +| feishu_mobile | CharField(40) | varchar(40) | 否 | 飞书手机号,兜底 | +| is_active | BooleanField | bool | 是 | 是否启用 | +| remark | CharField(255) | varchar(255) | 否 | 备注 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | +| updated_at | DateTimeField | datetime | 是 | 更新时间 | + +约束: + +| 约束名 | 字段 | 说明 | +| --- | --- | --- | +| uq_ra_feishu_mapping_user | system_user_id | 一个系统用户首期只维护一条启用映射 | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_feishu_mapping_active | is_active | 后台筛选启用映射 | +| idx_ra_feishu_mapping_open | feishu_open_id | 后续私聊事件反查用户 | +| idx_ra_feishu_mapping_userid | feishu_user_id | 后续私聊事件反查用户 | +| idx_ra_feishu_mapping_mobile | feishu_mobile | 手机号兜底查询 | + +校验规则: + +| 规则 | 说明 | +| --- | --- | +| 至少一个飞书标识 | `feishu_open_id`、`feishu_user_id`、`feishu_mobile` 至少填写一个 | +| @ 优先级 | `feishu_open_id -> feishu_user_id -> feishu_mobile` | + +--- + +### 3.2 ra_workflow_notification_record + +通用工作流通知记录表。用于记录自动汇总、法规核查、自动填表的飞书通知发送结果。现有专项通知表可继续保留,后续逐步收敛到本表。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| workflow_type | CharField(40) | varchar(40) | 是 | file_summary、regulatory_review、application_form_fill | +| workflow_batch_id | PositiveBigIntegerField | bigint | 是 | 对应工作流批次 ID | +| workflow_batch_no | CharField(80) | varchar(80) | 是 | 批次编号冗余,便于展示 | +| workflow_status | CharField(40) | varchar(40) | 是 | success、partial_success、failed 等 | +| dedupe_key | CharField(160) | varchar(160) | 是 | 判重键 | +| trigger_user_id | ForeignKey | bigint | 是 | 发起人或上传人 | +| feishu_mapping_id | ForeignKey | bigint | 否 | 命中的飞书用户映射 | +| channel | CharField(40) | varchar(40) | 是 | mock、feishu_api、disabled | +| target | CharField(160) | varchar(160) | 否 | 指定个人账号名称、open_id、user_id 或目标标识 | +| at_display_name | CharField(120) | varchar(120) | 否 | 被 @ 人展示名 | +| at_identifier_type | CharField(30) | varchar(30) | 否 | open_id、user_id、mobile、missing | +| at_identifier_masked | CharField(120) | varchar(120) | 否 | 脱敏后的 @ 标识 | +| send_status | CharField(30) | varchar(30) | 是 | pending、success、failed、skipped_duplicate、disabled | +| message_title | CharField(200) | varchar(200) | 是 | 通知标题 | +| message_summary | TextField | text | 否 | 发送摘要,不保存完整 payload | +| result_url | CharField(500) | varchar(500) | 否 | 系统结果入口 | +| external_message_id | CharField(120) | varchar(120) | 否 | Webhook 一般为空,API 发送时保存 | +| error_code | CharField(80) | varchar(80) | 否 | 飞书或客户端错误码 | +| error_message | TextField | text | 否 | 失败原因 | +| request_duration_ms | PositiveIntegerField | integer | 否 | HTTP 请求耗时 | +| sent_at | DateTimeField | datetime | 否 | 成功发送时间 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | +| updated_at | DateTimeField | datetime | 是 | 更新时间 | + +唯一约束: + +| 约束名 | 字段 | 说明 | +| --- | --- | --- | +| uq_ra_notify_dedupe_key | dedupe_key | 同一批次、流程、状态只保留一个成功发送意图 | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_notify_workflow | workflow_type, workflow_batch_id | 批次详情页查询通知 | +| idx_ra_notify_user_created | trigger_user_id, created_at | 用户通知历史 | +| idx_ra_notify_status | send_status, created_at | 排查失败通知 | +| idx_ra_notify_batch_no | workflow_batch_no | 按批次编号检索 | + +dedupe_key 生成规则: + +```text +{workflow_type}:{workflow_batch_id}:{workflow_status} +``` + +--- + +### 3.3 ra_feishu_question_log + +飞书问答日志预留表。首期可创建表但不接入事件回调;后续私聊问答 MVP 使用该表记录问题、意图、查询对象、回答摘要和错误信息。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| system_user_id | ForeignKey | bigint | 否 | 识别出的系统用户 | +| feishu_mapping_id | ForeignKey | bigint | 否 | 命中的飞书映射 | +| feishu_open_id | CharField(120) | varchar(120) | 否 | 事件中的 open_id | +| feishu_user_id | CharField(120) | varchar(120) | 否 | 事件中的 user_id | +| source_type | CharField(30) | varchar(30) | 是 | private_chat、group_mention | +| message_id | CharField(120) | varchar(120) | 否 | 飞书消息 ID | +| question_text | TextField | text | 是 | 用户原始问题 | +| intent | CharField(60) | varchar(60) | 否 | batch_status、risk_summary、export_summary 等 | +| query_object | JSONField | text/json | 是 | 批次号、工作流类型、最近批次等查询对象 | +| answer_summary | TextField | text | 否 | 回答摘要,不保存完整回答正文 | +| permission_result | CharField(40) | varchar(40) | 否 | allowed、denied、unbound | +| status | CharField(30) | varchar(30) | 是 | success、failed、ignored | +| error_message | TextField | text | 否 | 异常说明 | +| processed_at | DateTimeField | datetime | 否 | 处理完成时间 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_feishu_q_user_created | system_user_id, created_at | 用户问答历史 | +| idx_ra_feishu_q_intent | intent, created_at | 按意图分析 | +| idx_ra_feishu_q_status | status, created_at | 排查失败问答 | +| idx_ra_feishu_q_message | message_id | 消息幂等 | + +--- + +## 四、状态枚举 + +### 4.1 WorkflowNotificationRecord.channel + +| 值 | 说明 | +| --- | --- | +| mock | 模拟通知 | +| disabled | 真实通知未启用 | +| feishu_api | 飞书官方智能体/企业自建应用消息 API | +| feishu_webhook | 备选自定义机器人 Webhook,非首期主方案 | + +### 4.2 WorkflowNotificationRecord.send_status + +| 值 | 说明 | +| --- | --- | +| pending | 待发送 | +| success | 发送成功 | +| failed | 发送失败 | +| skipped_duplicate | 重复通知跳过 | +| disabled | 未启用真实发送 | + +### 4.3 FeishuQuestionLog.intent + +| 值 | 说明 | +| --- | --- | +| batch_status | 查询批次状态 | +| risk_summary | 查询风险摘要 | +| missing_summary | 查询缺失摘要 | +| export_summary | 查询导出摘要 | +| unknown | 未识别 | + +--- + +## 五、与现有表的兼容关系 + +| 现有表 | 处理建议 | +| --- | --- | +| `ra_regulatory_notification_record` | 保留现有数据;法规核查真实飞书通知可新增写入通用表,后续再决定是否迁移 | +| `ra_application_form_fill_notification_record` | 保留现有数据;自动填表通知状态展示可优先读通用表,兼容旧表 | +| `ra_exported_summary_file` | 通知摘要中的导出文件数量来自该表 | +| `ra_workflow_event` | 可记录通知节点事件,但不替代通知记录表 | +| `auth_user` | 飞书映射通过外键关联系统用户 | + +--- + +## 六、数据脱敏与安全 + +| 数据 | 入库策略 | +| --- | --- | +| App ID | 不入库,只在环境变量中维护 | +| App Secret | 不入库,只在环境变量中维护 | +| tenant_access_token | 不持久化入库,仅允许进程内短期缓存 | +| 富文本完整 payload | 不入库 | +| 手机号 | 映射表保存原值;通知记录只保存脱敏值 | +| open_id/user_id | 映射表保存原值;通知记录保存脱敏值 | +| 用户问题 | 问答日志保存原始问题,用于审计;不保存完整回答正文 | + +--- + +## 七、迁移计划 + +| 步骤 | 说明 | +| --- | --- | +| 1 | 新增 `FeishuUserMapping` 模型和迁移 | +| 2 | 新增 `WorkflowNotificationRecord` 模型和迁移 | +| 3 | 新增 `FeishuQuestionLog` 预留模型和迁移 | +| 4 | 注册 Django Admin 管理入口 | +| 5 | 批次详情页查询通用通知记录展示 | +| 6 | 保留现有专项通知表,不做破坏性迁移 | + +--- + +## 八、验收 SQL 示例 + +查询某个批次通知状态: + +```sql +SELECT workflow_type, workflow_batch_no, workflow_status, channel, send_status, sent_at, error_message +FROM ra_workflow_notification_record +WHERE workflow_type = 'application_form_fill' + AND workflow_batch_no = 'AFF-20260607-001' +ORDER BY created_at DESC; +``` + +查询未配置飞书映射的失败或降级通知: + +```sql +SELECT workflow_type, workflow_batch_no, trigger_user_id, send_status, message_summary +FROM ra_workflow_notification_record +WHERE at_identifier_type = 'missing' +ORDER BY created_at DESC; +``` + +查询飞书用户映射: + +```sql +SELECT u.username, m.feishu_display_name, m.feishu_open_id, m.feishu_user_id, m.feishu_mobile, m.is_active +FROM ra_feishu_user_mapping m +JOIN auth_user u ON u.id = m.system_user_id +ORDER BY u.username; +``` diff --git a/docs/3.数据库设计/5.第1章监管信息材料包生成.md b/docs/3.数据库设计/5.第1章监管信息材料包生成.md new file mode 100644 index 0000000..476329b --- /dev/null +++ b/docs/3.数据库设计/5.第1章监管信息材料包生成.md @@ -0,0 +1,590 @@ +# 第1章监管信息材料包生成数据库设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/5.第1章监管信息材料包生成.md | +| 功能设计文档 | docs/2.功能设计/5.第1章监管信息材料包生成.md | +| 数据库类型 | SQLite / Django ORM | +| 表名前缀 | ra_ | +| 工作流编码 | regulatory_info_package | +| 设计日期 | 2026-06-10 | +| 设计版本 | V1.0 | + +--- + +## 一、设计原则 + +| 原则 | 说明 | +| --- | --- | +| 独立工作流批次 | 第1章监管信息材料包生成使用独立批次表,不复用自动填表批次 | +| 附件优先 | 输入说明书优先绑定 `FileAttachment`,兼容最近成功 `FileSummaryBatch` 与 `FileSummaryItem` | +| 过程产物文件化 | 大 JSON、追溯清单、模板副本、生成文件和 zip 均保存为文件,数据库只保存路径、hash、摘要 | +| 导出记录复用 | zip、单文件、追溯清单继续写入 `ExportedSummaryFile`,统一下载权限 | +| 工作流通用表复用 | 节点状态和 SSE 事件复用 `WorkflowNodeRun`、`WorkflowEvent` | +| 通知独立留痕 | 新增专项通知记录表,结构与自动填表通知记录保持一致 | +| SQLite 兼容 | 使用 Django ORM 常规字段和 JSONField,避免数据库特定语法 | +| 原始模板保护 | 数据库只记录批次工作目录产物,不记录对原始模板的写操作 | + +--- + +## 二、ER 图 + +```mermaid +erDiagram + AUTH_USER ||--o{ CONVERSATION : owns + CONVERSATION ||--o{ MESSAGE : contains + CONVERSATION ||--o{ RA_FILE_ATTACHMENT : has + CONVERSATION ||--o{ RA_REGULATORY_INFO_PACKAGE_BATCH : has + AUTH_USER ||--o{ RA_REGULATORY_INFO_PACKAGE_BATCH : runs + MESSAGE ||--o{ RA_REGULATORY_INFO_PACKAGE_BATCH : triggers + RA_FILE_ATTACHMENT ||--o{ RA_REGULATORY_INFO_PACKAGE_BATCH : provides_instruction + RA_FILE_SUMMARY_BATCH ||--o{ RA_REGULATORY_INFO_PACKAGE_BATCH : optionally_feeds + RA_REGULATORY_INFO_PACKAGE_BATCH ||--o{ RA_REGULATORY_INFO_PACKAGE_ARTIFACT : keeps + RA_REGULATORY_INFO_PACKAGE_BATCH ||--o{ RA_REGULATORY_INFO_PACKAGE_NOTIFICATION_RECORD : sends + RA_REGULATORY_INFO_PACKAGE_BATCH ||--o{ RA_EXPORTED_SUMMARY_FILE : exports + RA_REGULATORY_INFO_PACKAGE_BATCH ||--o{ RA_WORKFLOW_NODE_RUN : tracks + RA_REGULATORY_INFO_PACKAGE_BATCH ||--o{ RA_WORKFLOW_EVENT : emits +``` + +说明:`ra_workflow_node_run`、`ra_workflow_event`、`ra_exported_summary_file` 通过 `workflow_type` 与 `workflow_batch_id` 支持多工作流。本功能统一使用 `workflow_type=regulatory_info_package`。 + +现状补充:当前通用节点表已有 `batch + node_code` 唯一约束主要服务文件汇总批次。RIP 批次不应强依赖 `FileSummaryBatch.batch`,因此实现时必须为 `workflow_type + workflow_batch_id + node_code` 增加数据库唯一约束,或在创建节点时使用同等幂等逻辑,避免同一 RIP 批次重复初始化节点。 + +--- + +## 三、表结构设计 + +### 3.1 ra_regulatory_info_package_batch + +一次第1章监管信息材料包生成工作流批次。记录触发来源、输入说明书、产品名称、生成状态、待确认摘要、zip 名称、配置版本和工作目录。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| conversation_id | ForeignKey | bigint | 是 | 所属对话 | +| user_id | ForeignKey | bigint | 是 | 发起用户 | +| trigger_message_id | ForeignKey | bigint | 否 | 触发本工作流的用户消息 | +| source_attachment_id | ForeignKey | bigint | 否 | 直接选中的说明书附件 | +| source_summary_batch_id | ForeignKey | bigint | 否 | 可选,最近成功文件汇总批次 | +| source_summary_item_id | PositiveBigIntegerField | integer | 否 | 可选,文件汇总条目 ID | +| batch_no | CharField(64) | varchar(64) | 是 | 批次编号,格式 `RIP-YYYYMMDDHHMMSS-abcdef`,唯一 | +| status | CharField(30) | varchar(30) | 是 | pending、running、waiting_user、success、partial_success、failed、cancelled | +| source_file_name | CharField(255) | varchar(255) | 否 | 说明书原文件名 | +| source_storage_path | CharField(500) | varchar(500) | 否 | 说明书存储路径 | +| product_name | CharField(200) | varchar(200) | 否 | 抽取到的产品名称 | +| output_zip_name | CharField(255) | varchar(255) | 否 | 主输出 zip 文件名,默认 `第1章 监管信息(预生成版).zip` | +| generated_files | JSONField | text/json | 是 | 7 个文件生成状态摘要 | +| missing_fields | JSONField | text/json | 是 | 缺失并填 `/` 的字段 | +| llm_only_fields | JSONField | text/json | 是 | 仅 LLM 命中的字段 | +| conflict_fields | JSONField | text/json | 是 | 规则和 LLM 冲突字段 | +| risk_notes | JSONField | text/json | 是 | `.doc` 适配器、知识库不可用、zip 失败等提示 | +| template_config_version | CharField(80) | varchar(80) | 否 | 模板配置版本 | +| template_config_hash | CharField(128) | varchar(128) | 否 | 模板配置 hash | +| adapter_summary | JSONField | text/json | 是 | docx/doc 适配器使用情况 | +| work_dir | CharField(500) | varchar(500) | 否 | 批次工作目录 | +| error_message | TextField | text | 否 | 批次异常说明 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | +| started_at | DateTimeField | datetime | 否 | 开始时间 | +| finished_at | DateTimeField | datetime | 否 | 完成时间 | +| archived_at | DateTimeField | datetime | 否 | 归档时间 | +| is_deleted | BooleanField | bool | 是 | 软删除标记 | + +唯一约束: + +| 约束名 | 字段 | +| --- | --- | +| uq_ra_rip_batch_no | batch_no | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_rip_batch_conv_status | conversation_id, status | 查询对话下材料包批次状态 | +| idx_ra_rip_batch_user_created | user_id, created_at | 查询用户发起历史 | +| idx_ra_rip_batch_attachment | source_attachment_id | 查询某说明书附件生成历史 | +| idx_ra_rip_batch_summary | source_summary_batch_id | 查询文件汇总关联的材料包批次 | +| idx_ra_rip_batch_created | created_at | 后台按时间排查 | + +--- + +### 3.2 ra_regulatory_info_package_artifact + +第1章监管信息材料包生成过程产物表。仅保存文件元数据,不保存大文本正文。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| batch_id | ForeignKey | bigint | 是 | 所属材料包批次 | +| artifact_type | CharField(60) | varchar(60) | 是 | template_copy、instruction_extract、field_extract_result、merged_fields、generated_document、traceability、zip_package、notification_record | +| file_format | CharField(20) | varchar(20) | 是 | json、excel、docx、doc、zip、markdown | +| name | CharField(160) | varchar(160) | 是 | 产物名称 | +| file_name | CharField(255) | varchar(255) | 是 | 文件名 | +| storage_path | CharField(500) | varchar(500) | 是 | 文件存储路径 | +| file_size | BigIntegerField | bigint | 是 | 文件大小 | +| content_hash | CharField(128) | varchar(128) | 否 | 文件 SHA-256 hash | +| metadata | JSONField | text/json | 是 | 模板编码、生成状态、高亮数量、适配器、错误摘要等 | +| created_by_node | CharField(60) | varchar(60) | 否 | 生成该产物的工作流节点 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | +| is_deleted | BooleanField | bool | 是 | 软删除标记 | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_rip_artifact_batch_type | batch_id, artifact_type | 查询批次过程产物 | +| idx_ra_rip_artifact_format | file_format | 按文件格式查询 | +| idx_ra_rip_artifact_created | created_at | 按时间追溯 | + +--- + +### 3.3 ra_regulatory_info_package_notification_record + +第1章监管信息材料包生成通知记录表。通知失败不阻断下载,但需要留痕和支持后续重试。 + +| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| id | BigAutoField | integer | 是 | 主键 | +| batch_id | ForeignKey | bigint | 是 | 所属材料包批次 | +| recipient_id | ForeignKey(User) | bigint | 是 | 通知对象,默认发起人 | +| channel | CharField(30) | varchar(30) | 是 | feishu_cli、feishu_api、mock | +| export_ids | JSONField | text/json | 是 | 本次通知关联导出文件 ID | +| message_summary | TextField | text | 是 | 通知摘要 | +| send_status | CharField(20) | varchar(20) | 是 | pending、success、failed | +| retry_count | PositiveIntegerField | integer | 是 | 已重试次数 | +| external_message_id | CharField(120) | varchar(120) | 否 | 飞书外部消息 ID | +| error_message | TextField | text | 否 | 失败原因 | +| sent_at | DateTimeField | datetime | 否 | 发送成功时间 | +| created_at | DateTimeField | datetime | 是 | 创建时间 | +| updated_at | DateTimeField | datetime | 是 | 更新时间 | +| is_deleted | BooleanField | bool | 是 | 软删除标记 | + +索引: + +| 索引名 | 字段 | 说明 | +| --- | --- | --- | +| idx_ra_rip_notify_batch | batch_id, created_at | 查询批次通知 | +| idx_ra_rip_notify_recipient | recipient_id, send_status | 查询用户通知状态 | +| idx_ra_rip_notify_status | send_status, retry_count | 查询待重试通知 | + +--- + +## 四、既有表扩展 + +### 4.1 ra_exported_summary_file + +继续复用导出文件表,新增 zip 导出类型,并支持 `regulatory_info_package` 权限反查。 + +| 字段/枚举 | 处理 | +| --- | --- | +| export_type | 增加 `zip` | +| workflow_type | 使用 `regulatory_info_package` | +| workflow_batch_id | 记录 `RegulatoryInfoPackageBatch.id` | +| export_category | 使用 `regulatory_info_package`、`generated_document`、`traceability` | + +导出类型枚举: + +| value | 中文展示 | 说明 | +| --- | --- | --- | +| markdown | Markdown | 既有报告 | +| excel | Excel | 追溯清单 | +| json | JSON | 抽取结果、合并字段 | +| word | Word | 生成的 Word 文件,包含 `.docx` 和可下载 `.doc` | +| pdf | PDF | 既有预留 | +| zip | ZIP | 第1章监管信息材料包主下载 | + +下载 MIME 规则: + +| 条件 | content_type | +| --- | --- | +| export_type=zip | application/zip | +| export_type=word 且文件名后缀 `.doc` | application/msword | +| export_type=word 且文件名后缀 `.docx` | application/vnd.openxmlformats-officedocument.wordprocessingml.document | + +### 4.2 ra_workflow_node_run + +本功能使用通用工作流节点表: + +| 字段 | 值 | +| --- | --- | +| workflow_type | regulatory_info_package | +| workflow_batch_id | RegulatoryInfoPackageBatch.id | +| node_group | regulatory_info_package | +| batch_id | 可为空;如为兼容旧查询,不建议绑定文件汇总批次 | + +幂等约束建议: + +| 约束/策略 | 字段 | 说明 | +| --- | --- | --- | +| uq_ra_node_workflow_batch_code | workflow_type, workflow_batch_id, node_code | 推荐新增数据库唯一约束,防止同一 RIP 批次重复节点 | +| get_or_create 幂等 | workflow_type, workflow_batch_id, node_code | 若暂不改通用表约束,节点初始化必须使用该组合做代码层幂等 | + +建议新增节点: + +```text +prepare, template_copy, text_extract, field_extract, field_merge, +generate_docs, highlight_review_items, trace_export, zip_export, notify, completed +``` + +### 4.3 ra_workflow_event + +本功能事件写入: + +| 字段 | 值 | +| --- | --- | +| workflow_type | regulatory_info_package | +| workflow_batch_id | RegulatoryInfoPackageBatch.id | +| conversation_id | 当前对话 ID | +| payload | 节点状态、文件生成状态、导出 ID、待确认摘要等 | + +--- + +## 五、枚举设计 + +### 5.1 RegulatoryInfoPackageBatch.status + +| value | 中文展示 | 说明 | +| --- | --- | --- | +| pending | 待执行 | 批次已创建,等待执行 | +| running | 执行中 | 工作流正在执行 | +| waiting_user | 等待用户 | 未找到唯一说明书,需要用户选择 | +| success | 成功 | 7 个文件、zip 和必要追溯产物生成成功 | +| partial_success | 部分成功 | zip 或主要文件已生成,但部分单文件、`.doc` 原生处理、`.docx` 兜底、追溯或通知存在失败 | +| failed | 失败 | 关键输入、模板或全部目标文件生成失败 | +| cancelled | 已取消 | 用户或系统取消执行 | + +### 5.2 RegulatoryInfoPackageArtifact.artifact_type + +| value | 说明 | +| --- | --- | +| template_copy | 模板副本 | +| instruction_extract | 说明书文本、章节、表格抽取结果 | +| field_extract_result | 规则与 LLM 抽取原始结果 | +| merged_fields | 合并字段、高亮决策、标准候选 | +| generated_document | 生成后的单个目标文件 | +| traceability | 追溯清单 | +| zip_package | 主下载 zip 包 | +| notification_record | 通知记录产物 | + +### 5.3 RegulatoryInfoPackageArtifact.file_format + +| value | 说明 | +| --- | --- | +| json | JSON 产物 | +| excel | Excel 追溯清单 | +| docx | Word OpenXML 文件 | +| doc | Word 97-2003 文件 | +| zip | 压缩包 | +| markdown | Markdown 摘要或报告 | + +### 5.4 通知枚举 + +| 字段 | value | +| --- | --- | +| channel | feishu_cli、feishu_api、mock | +| send_status | pending、success、failed | + +--- + +## 六、JSON 字段结构 + +### 6.1 generated_files + +```json +[ + { + "template_code": "ch1_4_application_form", + "file_name": "CH1.4 申请表.docx", + "status": "success", + "artifact_id": 12, + "export_id": 34, + "highlight_count": 8, + "missing_count": 5, + "llm_only_count": 2, + "error_message": "" + } +] +``` + +### 6.2 missing_fields + +```json +[ + { + "target_file": "CH1.4 申请表.docx", + "field_key": "applicant_name", + "field_label": "申请人名称", + "final_value": "/", + "highlight_reason": "missing", + "needs_review": true + } +] +``` + +### 6.3 llm_only_fields + +```json +[ + { + "target_file": "CH1.4 申请表.docx", + "field_key": "detection_targets", + "field_label": "检测靶标", + "final_value": "ORF1ab、N基因", + "evidence": "预期用途和检测原理章节", + "highlight_reason": "llm_only", + "needs_review": true + } +] +``` + +### 6.4 conflict_fields + +```json +[ + { + "field_key": "package_specification", + "field_label": "包装规格", + "rule_value": "规格A:24人份/盒、48人份/盒、96人份/盒", + "llm_value": "规格A、规格B均为24/48/96人份", + "selected_value": "规格A:24人份/盒、48人份/盒、96人份/盒", + "handling": "规则优先,写入值高亮并进入追溯清单" + } +] +``` + +### 6.5 risk_notes + +```json +[ + { + "type": "legacy_doc_adapter_unavailable", + "message": "CH1.9 为 .doc 文件,当前环境未检测到可写入适配器。", + "template_code": "ch1_9_pre_submission" + }, + { + "type": "knowledge_base_unavailable", + "message": "标准清单知识库查询不可用,未自动写入候选标准。" + } +] +``` + +### 6.6 adapter_summary + +```json +{ + "docx": { + "adapter": "DocxDocumentAdapter", + "status": "available" + }, + "doc": { + "adapter": "WordComDocAdapter", + "status": "available", + "fallback_used": false + } +} +``` + +### 6.7 artifact.metadata + +```json +{ + "template_code": "ch1_5_product_list", + "strategy": "product_list", + "source_template": "CH1.5 产品列表.docx", + "generated_status": "success", + "highlight_count": 12, + "missing_count": 6, + "llm_only_count": 1, + "adapter": "DocxDocumentAdapter", + "created_by_node": "generate_docs" +} +``` + +--- + +## 七、存储路径设计 + +批次目录: + +```text +media/regulatory_info_package/{user_id}/{conversation_id}/{batch_no}/ +``` + +目录结构: + +```text +media/regulatory_info_package/12/1001/RIP-20260610153000-abcdef/ + templates/ + ch1_2_directory.source.docx + ch1_9_pre_submission.source.doc + extracted/ + instruction_extract.json + field_extract_result.json + merged_fields.json + generated/ + CH1.2 监管信息目录.docx + CH1.4 申请表.docx + CH1.5 产品列表.docx + CH1.9 产品申报前沟通的说明.doc + CH1.11.1 符合标准的清单.docx + CH1.11.5 真实性声明.docx + CH1.11.6 符合性声明.docx + exports/ + traceability.xlsx + 第1章 监管信息(预生成版).zip + logs/ + instruction_extract.json + field_extract_result.json + merged_fields.json + traceability.json + doc_adapter_result.json +``` + +路径安全要求: + +| 要求 | 说明 | +| --- | --- | +| 输出目录校验 | 所有输出路径必须位于当前批次 `work_dir` 下 | +| 原始模板只读 | 不允许覆盖 `docs/0.原始材料` | +| 导出路径 | `ExportedSummaryFile.storage_path` 保存实际文件路径,下载时校验权限 | + +--- + +## 八、权限关系 + +### 8.1 批次权限 + +```text +RegulatoryInfoPackageBatch.conversation.user_id == request.user.id +``` + +### 8.2 输入附件权限 + +```text +FileAttachment.conversation_id == batch.conversation_id +FileAttachment.user_id == batch.user_id +FileAttachment.upload_status != deleted +``` + +### 8.3 导出下载权限 + +`ExportedSummaryFile` 下载时按 `workflow_type` 分支: + +```text +workflow_type == "regulatory_info_package" +-> workflow_batch_id 反查 RegulatoryInfoPackageBatch +-> conversation__user == request.user +-> is_deleted == false +``` + +--- + +## 九、迁移设计 + +建议新增一个迁移文件,包含: + +| 变更 | 说明 | +| --- | --- | +| 新增 `RegulatoryInfoPackageBatch` | 批次表 | +| 新增 `RegulatoryInfoPackageArtifact` | 产物表 | +| 新增 `RegulatoryInfoPackageNotificationRecord` | 通知记录表 | +| 扩展 `ExportedSummaryFile.ExportType` | 增加 `zip` 枚举 | + +Django 模型建议仍集中放在 `review_agent/models.py`,业务逻辑放入 `review_agent/regulatory_info_package/`。 + +--- + +## 十、DDL 参考 + +以下 DDL 为 SQLite / Django ORM 参考,实际以 migration 生成为准。 + +```sql +CREATE TABLE ra_regulatory_info_package_batch ( + id integer NOT NULL PRIMARY KEY AUTOINCREMENT, + conversation_id bigint NOT NULL REFERENCES review_agent_conversation(id), + user_id bigint NOT NULL REFERENCES auth_user(id), + trigger_message_id bigint NULL REFERENCES review_agent_message(id), + source_attachment_id bigint NULL REFERENCES ra_file_attachment(id), + source_summary_batch_id bigint NULL REFERENCES ra_file_summary_batch(id), + source_summary_item_id integer NULL, + batch_no varchar(64) NOT NULL UNIQUE, + status varchar(30) NOT NULL, + source_file_name varchar(255) NOT NULL DEFAULT '', + source_storage_path varchar(500) NOT NULL DEFAULT '', + product_name varchar(200) NOT NULL DEFAULT '', + output_zip_name varchar(255) NOT NULL DEFAULT '', + generated_files text NOT NULL DEFAULT '[]', + missing_fields text NOT NULL DEFAULT '[]', + llm_only_fields text NOT NULL DEFAULT '[]', + conflict_fields text NOT NULL DEFAULT '[]', + risk_notes text NOT NULL DEFAULT '[]', + template_config_version varchar(80) NOT NULL DEFAULT '', + template_config_hash varchar(128) NOT NULL DEFAULT '', + adapter_summary text NOT NULL DEFAULT '{}', + work_dir varchar(500) NOT NULL DEFAULT '', + error_message text NOT NULL DEFAULT '', + created_at datetime NOT NULL, + started_at datetime NULL, + finished_at datetime NULL, + archived_at datetime NULL, + is_deleted bool NOT NULL DEFAULT 0 +); + +CREATE INDEX idx_ra_rip_batch_conv_status + ON ra_regulatory_info_package_batch(conversation_id, status); +CREATE INDEX idx_ra_rip_batch_user_created + ON ra_regulatory_info_package_batch(user_id, created_at); +CREATE INDEX idx_ra_rip_batch_attachment + ON ra_regulatory_info_package_batch(source_attachment_id); +CREATE INDEX idx_ra_rip_batch_summary + ON ra_regulatory_info_package_batch(source_summary_batch_id); +CREATE INDEX idx_ra_rip_batch_created + ON ra_regulatory_info_package_batch(created_at); +``` + +--- + +## 十一、实现注意事项 + +| 注意事项 | 说明 | +| --- | --- | +| JSONField 默认值 | 使用 `default=list` 或 `default=dict`,禁止使用可变对象字面量 | +| 外键删除策略 | conversation/user 使用 CASCADE;输入附件和文件汇总批次建议 PROTECT 或 SET_NULL,避免历史批次断链 | +| `source_summary_item_id` | 当前没有强制外键到 `FileSummaryItem`,可先保存 ID,后续需要强约束时再改 FK | +| 工作流节点幂等 | RIP 节点不得只依赖 `WorkflowNodeRun.batch + node_code` 唯一约束;必须使用 `workflow_type + workflow_batch_id + node_code` 保证幂等 | +| `.doc` 失败记录 | `.doc` 原生适配器不可用或执行失败时必须写入 `risk_notes` 和 artifact metadata;若 `.docx` 兜底成功则 generated_files 状态为 `fallback_success` | +| zip 主入口 | zip 导出记录的 `export_category` 固定为 `regulatory_info_package` | +| 单文件下载 | 7 个生成文件也写入 `ExportedSummaryFile`,作为辅助下载 | +| 软删除 | 批次和产物使用 `is_deleted`,下载权限需过滤软删除批次 | + +--- + +## 十二、验收标准 + +| 序号 | 验收项 | 标准 | +| --- | --- | --- | +| 1 | 模型创建 | 三张 RIP 专项表可通过 migration 创建 | +| 2 | 批次编号 | `batch_no` 唯一,符合 `RIP-...` 格式 | +| 3 | 附件关联 | 批次可绑定直接说明书附件 | +| 4 | 汇总兼容 | 批次可选绑定 `FileSummaryBatch` 与 `source_summary_item_id` | +| 5 | 产物留痕 | 模板副本、抽取结果、生成文件、zip、追溯清单均可写 artifact | +| 6 | zip 导出 | `ExportedSummaryFile` 支持 `export_type=zip` | +| 7 | 下载权限 | 非批次所属用户不能下载 RIP 导出 | +| 8 | 节点事件 | `WorkflowNodeRun` 和 `WorkflowEvent` 可通过 `workflow_type=regulatory_info_package` 查询 | +| 9 | 节点幂等 | 同一 `workflow_type + workflow_batch_id + node_code` 不会重复创建节点 | +| 10 | 通知记录 | 通知成功、失败和重试次数可落库 | +| 11 | JSON 摘要 | 缺失项、LLM-only、冲突项、风险提示结构符合本文约定 | + +--- + +## 十三、规范依据与裁决 + +| 规范来源 | 命中规则 | 本设计裁决 | +| --- | --- | --- | +| GYRX 数据库设计流程 | 项目规范优先,未命中时回退基线规范 | 当前项目为 Django/SQLite,沿用既有数据库设计文档风格 | +| 既有自动填表数据库设计 | 独立批次、产物、通知三表;大 JSON 文件化;通用导出表复用 | 本功能按同样模式新增 RIP 三表 | +| 自动汇总数据库设计 | 对话隔离、多版本附件、工作流事件留痕 | 输入附件和批次权限沿用该关系 | +| 飞书通知数据库设计 | 通知摘要入库、失败不阻断主流程 | RIP 通知表结构与自动填表通知对齐 | + +冲突裁决:技能规范中的低代码/Java 表达不适用于当前 Django 项目,数据库设计以当前项目 ORM、SQLite 兼容和既有 `ra_` 表风格为准。 diff --git a/docs/4.详细设计/1.自动汇总.md b/docs/4.详细设计/1.自动汇总.md new file mode 100644 index 0000000..832534c --- /dev/null +++ b/docs/4.详细设计/1.自动汇总.md @@ -0,0 +1,930 @@ +# 自动汇总文件夹文件目录与页数流程详细设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/1.自动汇总.md | +| 功能设计文档 | docs/2.功能设计/1.自动汇总.md | +| 功能名称 | 自动汇总文件夹文件目录与页数 | +| 所属模块 | 审核智能体 review_agent | +| 设计日期 | 2026-06-05 | +| 设计版本 | V1.0 | + +--- + +## 一、详细设计目标 + +本详细设计用于指导“自动汇总文件夹文件目录与页数”功能开发落地,覆盖代码目录、数据模型、接口契约、后台工作流、Skill 拆分、轻量依赖、前端三栏布局、SSE 实时状态、异常重试和测试用例。 + +核心约束: + +| 约束 | 说明 | +| --- | --- | +| 对话绑定 | 上传文件与当前 Conversation 绑定,一个对话对应一套文件,不能串文件 | +| 上传即存储 | 用户拖拽或选择文件后立即保存,但不启动工作流 | +| 提示词触发 | 用户发送消息后,根据提示词判断是否启动自动汇总工作流 | +| 后台异步 | 工作流后台执行,右侧第三栏工作流卡片实时更新 | +| 轻量依赖 | 优先使用 Python 内部库和轻量第三方库,不强依赖 LibreOffice | +| 老格式支持 | doc、xls、ppt 进入处理流程,能读到页数则统计,读不到则记录异常 | +| 结果存档 | 批次、文件、节点、事件、明细、导出文件全部入库 | + +--- + +## 二、代码结构设计 + +### 2.1 目录结构 + +在现有 `review_agent` 应用内按模块重新划分文件处理能力。Django 模型仍集中放在 `review_agent/models.py`,其余代码放入 `review_agent/file_summary/`。 + +```text +review_agent/ + models.py + urls.py + views.py + services.py + file_summary/ + __init__.py + constants.py + schemas.py + storage.py + workflow.py + events.py + urls.py + views.py + services/ + __init__.py + archive.py + inventory.py + page_count.py + product_detect.py + report.py + export_excel.py + workflow_trigger.py + skills/ + __init__.py + base.py + registry.py + upload_intake.py + archive_extract.py + file_inventory.py + document_page_count.py + product_detect.py + summary_report.py + excel_export.py +``` + +### 2.2 文件职责 + +| 文件 | 职责 | +| --- | --- | +| review_agent/models.py | 集中定义 Conversation、Message、文件汇总相关模型 | +| file_summary/constants.py | 状态、节点、文件类型、事件类型常量 | +| file_summary/schemas.py | dataclass 入参出参结构,避免业务层直接传散乱 dict | +| file_summary/storage.py | 上传文件、工作目录、导出文件路径生成与保存 | +| file_summary/workflow.py | WorkflowExecutor,串行执行节点图 | +| file_summary/events.py | 工作流事件持久化与 SSE 格式化 | +| file_summary/views.py | 上传暂存、启动工作流、状态查询、SSE、下载接口 | +| services/archive.py | 压缩包识别、zip/7z/rar 解压 | +| services/inventory.py | 文件遍历与清单生成 | +| services/page_count.py | 文件页数统计与 3 次重试 | +| services/product_detect.py | 产品名识别 | +| services/report.py | Markdown 报告和对话简表生成 | +| services/export_excel.py | Excel 文件导出 | +| services/workflow_trigger.py | 根据提示词判断是否触发自动汇总工作流 | +| skills/base.py | Skill 基类与统一返回结构 | +| skills/registry.py | Skill 注册与按需加载 | +| skills/*.py | 各工作流节点对应 Skill | + +--- + +## 三、依赖设计 + +### 3.1 requirements 建议 + +```text +Django==5.2.14 +pypdf +python-docx +python-pptx +openpyxl +xlrd +olefile +py7zr +``` + +### 3.2 格式处理策略 + +| 格式 | 处理库 | 统计口径 | 失败策略 | +| --- | --- | --- | --- | +| pdf | pypdf | PDF 页面数 | 重试 3 次,仍失败记录异常 | +| docx | python-docx | 优先读取内置页数属性 | 读不到记录“页数不可确定” | +| doc | olefile | 读取 OLE 元数据页数 | 读不到记录“页数不可确定” | +| pptx | python-pptx | 幻灯片数量 | 重试 3 次,仍失败记录异常 | +| ppt | olefile | 读取 OLE 元数据页数/幻灯片数 | 读不到记录“页数不可确定” | +| xlsx | openpyxl | 工作表数量 | 重试 3 次,仍失败记录异常 | +| xls | xlrd | 工作表数量 | 重试 3 次,仍失败记录异常 | + +### 3.3 压缩包处理策略 + +| 格式 | 处理方式 | 说明 | +| --- | --- | --- | +| zip | Python 标准库 zipfile | 必须支持 | +| 7z | py7zr | 必须支持 | +| rar | 优先系统 7z 命令 | Docker 镜像需安装 7-Zip/p7zip | + +### 3.4 Docker 部署说明 + +Demo 运行不强依赖 LibreOffice。若未来要求 doc/docx/ppt/pptx 页数与 Office 打开后的分页完全一致,可在 Docker 镜像中额外安装 LibreOffice headless,再通过“转换 PDF 后统计页数”的增强策略实现。 + +RAR 解压如需稳定支持,Docker 镜像需要安装 7-Zip/p7zip,并确保 `7z` 命令在 PATH 中可调用。 + +--- + +## 四、数据模型详细设计 + +模型集中放在 `review_agent/models.py`,按“会话模型”和“文件汇总模型”分段。 + +### 4.1 FileAttachment + +用户上传即存储的文件记录。此时尚未启动工作流。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| id | BigAutoField | PK | 主键 | +| conversation | ForeignKey(Conversation) | CASCADE, db_index | 绑定对话 | +| user | ForeignKey(User) | CASCADE, db_index | 上传用户 | +| original_name | CharField(255) | required | 原始文件名 | +| storage_path | CharField(500) | required | 本地保存路径 | +| file_size | BigIntegerField | default=0 | 文件大小 | +| content_type | CharField(120) | blank | MIME 类型 | +| upload_status | CharField(20) | choices | uploaded、bound、deleted | +| created_at | DateTimeField | auto_now_add | 上传时间 | + +索引: + +```text +(conversation, created_at) +(user, created_at) +``` + +### 4.2 FileSummaryBatch + +一次自动汇总工作流批次。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| id | BigAutoField | PK | 主键 | +| conversation | ForeignKey(Conversation) | CASCADE, db_index | 绑定对话 | +| user | ForeignKey(User) | CASCADE, db_index | 执行用户 | +| trigger_message | ForeignKey(Message) | SET_NULL, null | 触发工作流的用户消息 | +| batch_no | CharField(64) | unique | 批次编号 | +| product_name | CharField(200) | blank | 产品名称 | +| status | CharField(20) | choices | pending、running、success、failed | +| total_files | IntegerField | default=0 | 文件总数 | +| supported_files | IntegerField | default=0 | 支持统计数 | +| success_files | IntegerField | default=0 | 成功数 | +| failed_files | IntegerField | default=0 | 失败数 | +| unsupported_files | IntegerField | default=0 | 不支持数 | +| uncertain_files | IntegerField | default=0 | 页数不可确定数 | +| total_pages | IntegerField | default=0 | 总页数 | +| work_dir | CharField(500) | blank | 工作目录 | +| error_message | TextField | blank | 批次错误 | +| created_at | DateTimeField | auto_now_add | 创建时间 | +| started_at | DateTimeField | null | 开始时间 | +| finished_at | DateTimeField | null | 结束时间 | + +### 4.3 FileSummaryBatchAttachment + +批次与上传文件的绑定表,确保工作流只读取本批次文件。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| id | BigAutoField | PK | 主键 | +| batch | ForeignKey(FileSummaryBatch) | CASCADE | 批次 | +| attachment | ForeignKey(FileAttachment) | CASCADE | 上传文件 | +| created_at | DateTimeField | auto_now_add | 绑定时间 | + +唯一约束: + +```text +unique(batch, attachment) +``` + +### 4.4 FileSummaryItem + +文件明细记录。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| id | BigAutoField | PK | 主键 | +| batch | ForeignKey(FileSummaryBatch) | CASCADE, db_index | 所属批次 | +| file_index | IntegerField | required | 文件序号 | +| directory_level | CharField(300) | blank | 目录层级 | +| file_name | CharField(255) | required | 文件名 | +| file_type | CharField(20) | required | 扩展名 | +| relative_path | CharField(500) | required | 相对路径 | +| storage_path | CharField(500) | required | 实际处理路径 | +| page_count | IntegerField | null | 页数 | +| statistics_status | CharField(20) | choices | success、failed、unsupported、uncertain、skipped | +| retry_count | IntegerField | default=0 | 重试次数 | +| error_message | TextField | blank | 异常说明 | +| created_at | DateTimeField | auto_now_add | 创建时间 | +| updated_at | DateTimeField | auto_now | 更新时间 | + +唯一约束: + +```text +unique(batch, relative_path) +``` + +### 4.5 WorkflowNodeRun + +工作流节点状态记录。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| id | BigAutoField | PK | 主键 | +| batch | ForeignKey(FileSummaryBatch) | CASCADE, db_index | 批次 | +| node_code | CharField(40) | required | 节点编码 | +| node_name | CharField(80) | required | 节点名称 | +| status | CharField(20) | choices | pending、running、retrying、success、failed、skipped | +| progress | IntegerField | default=0 | 进度百分比 | +| message | TextField | blank | 节点说明 | +| started_at | DateTimeField | null | 开始时间 | +| finished_at | DateTimeField | null | 完成时间 | + +唯一约束: + +```text +unique(batch, node_code) +``` + +### 4.6 WorkflowEvent + +SSE 事件持久化记录,用于页面刷新后恢复和调试。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| id | BigAutoField | PK | 主键 | +| batch | ForeignKey(FileSummaryBatch) | CASCADE, db_index | 批次 | +| event_type | CharField(40) | required | 事件类型 | +| payload | JSONField | default=dict | 事件载荷 | +| created_at | DateTimeField | auto_now_add | 创建时间 | + +### 4.7 ExportedSummaryFile + +导出文件记录。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| id | BigAutoField | PK | 主键 | +| batch | ForeignKey(FileSummaryBatch) | CASCADE, db_index | 批次 | +| export_type | CharField(20) | choices | markdown、excel | +| file_name | CharField(255) | required | 文件名 | +| storage_path | CharField(500) | required | 保存路径 | +| status | CharField(20) | choices | success、failed | +| error_message | TextField | blank | 异常 | +| created_at | DateTimeField | auto_now_add | 生成时间 | + +下载链接运行时根据 `export_id` 生成,不建议长期存储静态 URL。 + +--- + +## 五、常量与状态设计 + +### 5.1 支持格式 + +```python +SUPPORTED_PAGE_TYPES = {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx"} +ARCHIVE_TYPES = {"zip", "7z", "rar"} +``` + +### 5.2 工作流节点 + +```python +WORKFLOW_NODES = [ + ("upload", "上传中"), + ("extract", "解压中"), + ("inventory", "扫描中"), + ("page_count", "解析页数中"), + ("product_detect", "识别产品名中"), + ("report", "输出 Markdown 中"), + ("excel_export", "输出 Excel 中"), + ("completed", "已完成"), +] +``` + +### 5.3 触发词规则 + +`workflow_trigger.py` 先用规则判断,后续可升级为 LLM 意图识别。 + +```python +SUMMARY_TRIGGER_KEYWORDS = [ + "自动汇总", + "文件目录", + "页数", + "统计文件", + "汇总目录", + "目录与页数", +] +``` + +规则: + +| 条件 | 结果 | +| --- | --- | +| 当前对话存在未绑定或最近上传文件,且提示词命中关键词 | 启动自动汇总工作流 | +| 未命中关键词 | 走普通 LLM 对话 | +| 命中关键词但没有上传文件 | AI 回复提示“请先上传文件或压缩包” | + +--- + +## 六、服务与方法签名 + +### 6.1 storage.py + +```python +def save_attachment(conversation, user, uploaded_file) -> FileAttachment: + """保存上传文件并绑定当前对话。""" + +def build_batch_work_dir(batch: FileSummaryBatch) -> Path: + """生成批次工作目录。""" + +def build_export_path(batch: FileSummaryBatch, suffix: str) -> Path: + """生成导出文件路径。""" +``` + +存储目录: + +```text +media/review_agent/ + user_{user_id}/ + conversation_{conversation_id}/ + attachments/ + batches/ + batch_{batch_id}/ + input/ + extracted/ + exports/ +``` + +### 6.2 archive.py + +```python +def is_archive(path: Path) -> bool: + """判断是否压缩包。""" + +def extract_archive(source: Path, target_dir: Path) -> list[Path]: + """解压 zip、7z、rar,返回解压后的文件路径列表。""" + +def extract_zip(source: Path, target_dir: Path) -> list[Path]: + """使用 zipfile 解压。""" + +def extract_7z(source: Path, target_dir: Path) -> list[Path]: + """使用 py7zr 解压。""" + +def extract_rar(source: Path, target_dir: Path) -> list[Path]: + """优先调用系统 7z 命令解压 rar。""" +``` + +安全规则: + +| 规则 | 说明 | +| --- | --- | +| 路径穿越检查 | 解压后的最终路径必须仍在 target_dir 内 | +| 文件名清理 | 保留原名,但禁止绝对路径和上级目录跳转 | +| 解压失败 | 抛出 ArchiveExtractError,批次失败 | + +### 6.3 inventory.py + +```python +def scan_files(batch: FileSummaryBatch, roots: list[Path]) -> list[FileSummaryItem]: + """扫描目录或散装文件,创建 FileSummaryItem。""" + +def build_directory_level(relative_path: Path) -> str: + """根据相对路径生成目录层级。""" + +def normalize_file_type(path: Path) -> str: + """返回小写扩展名,不含点。""" +``` + +### 6.4 page_count.py + +```python +def count_pages(item: FileSummaryItem) -> PageCountResult: + """根据文件类型分发页数统计。""" + +def count_pages_with_retry(item: FileSummaryItem, max_retry: int = 3) -> PageCountResult: + """失败最多重试 3 次。""" + +def count_pdf(path: Path) -> int: + """使用 pypdf 统计 PDF 页数。""" + +def count_docx(path: Path) -> PageCountResult: + """使用 python-docx 读取内置页数属性。""" + +def count_doc(path: Path) -> PageCountResult: + """使用 olefile 读取老 doc 的 OLE 元数据页数。""" + +def count_xlsx(path: Path) -> int: + """使用 openpyxl 统计工作表数量。""" + +def count_xls(path: Path) -> int: + """使用 xlrd 统计工作表数量。""" + +def count_pptx(path: Path) -> int: + """使用 python-pptx 统计幻灯片数量。""" + +def count_ppt(path: Path) -> PageCountResult: + """使用 olefile 读取老 ppt 的 OLE 元数据页数或幻灯片数。""" +``` + +`PageCountResult`: + +```python +@dataclass +class PageCountResult: + status: str + page_count: int | None = None + error_message: str = "" +``` + +状态规则: + +| 情况 | status | page_count | +| --- | --- | --- | +| 成功读取页数 | success | 整数 | +| 不支持类型 | unsupported | None | +| 文件可读但页数无元数据 | uncertain | None | +| 解析异常且重试失败 | failed | None | + +### 6.5 product_detect.py + +```python +def detect_product_name(batch: FileSummaryBatch) -> ProductDetectResult: + """从目录名、文件名和少量元数据中识别产品名。""" + +def update_conversation_title(batch: FileSummaryBatch, product_name: str) -> None: + """按规则更新对话标题。""" +``` + +产品名识别优先级: + +| 优先级 | 来源 | +| --- | --- | +| 1 | 顶层目录名 | +| 2 | 文件名中包含“产品”“试剂盒”“说明书”等关键词的片段 | +| 3 | docx 文档属性 title | +| 4 | PDF 元数据 title | + +### 6.6 report.py + +```python +def build_summary_stats(batch: FileSummaryBatch) -> dict: + """汇总统计数据。""" + +def build_chat_markdown(batch: FileSummaryBatch) -> str: + """生成对话框展示 Markdown 简表。""" + +def build_full_markdown_report(batch: FileSummaryBatch) -> str: + """生成完整 Markdown 报告。""" + +def save_markdown_report(batch: FileSummaryBatch) -> ExportedSummaryFile: + """保存 Markdown 报告并创建导出记录。""" +``` + +### 6.7 export_excel.py + +```python +def build_excel_workbook(batch: FileSummaryBatch) -> Workbook: + """构建 Excel Workbook。""" + +def save_excel(batch: FileSummaryBatch) -> ExportedSummaryFile: + """保存 Excel 并创建导出记录。""" +``` + +工作表: + +| Sheet | 字段 | +| --- | --- | +| 汇总信息 | 批次编号、产品名、文件总数、成功数、失败数、不可确定数、总页数 | +| 文件明细 | 序号、目录层级、文件名、类型、页数、相对路径、状态、重试次数、异常说明 | + +--- + +## 七、Skill 详细设计 + +### 7.1 BaseSkill + +```python +class BaseSkill: + name: str + node_code: str + + def run(self, context: WorkflowContext) -> SkillResult: + raise NotImplementedError +``` + +`WorkflowContext`: + +```python +@dataclass +class WorkflowContext: + batch_id: int + conversation_id: int + user_id: int + message_id: int | None = None +``` + +`SkillResult`: + +```python +@dataclass +class SkillResult: + success: bool + message: str = "" + data: dict = field(default_factory=dict) +``` + +### 7.2 Skill 列表 + +| Skill 类名 | 节点 | 调用服务 | +| --- | --- | --- | +| UploadIntakeSkill | upload | storage.py | +| ArchiveExtractSkill | extract | archive.py | +| FileInventorySkill | inventory | inventory.py | +| DocumentPageCountSkill | page_count | page_count.py | +| ProductDetectSkill | product_detect | product_detect.py | +| SummaryReportSkill | report | report.py | +| ExcelExportSkill | excel_export | export_excel.py | + +--- + +## 八、工作流执行器详细设计 + +### 8.1 执行入口 + +```python +def start_file_summary_workflow(batch_id: int) -> None: + thread = threading.Thread( + target=WorkflowExecutor().run, + args=(batch_id,), + daemon=True, + ) + thread.start() +``` + +### 8.2 执行伪代码 + +```python +class WorkflowExecutor: + def run(self, batch_id: int) -> None: + batch = FileSummaryBatch.objects.get(pk=batch_id) + self.mark_batch_running(batch) + self.emit("workflow_started", batch, {"batch_id": batch.id}) + + try: + for node_code in self.resolve_nodes(batch): + self.run_node(batch, node_code) + self.mark_batch_success(batch) + self.emit("workflow_completed", batch, self.build_completed_payload(batch)) + except Exception as exc: + self.mark_batch_failed(batch, str(exc)) + self.emit("workflow_failed", batch, {"message": str(exc)}) +``` + +### 8.3 节点跳过规则 + +| 节点 | 跳过条件 | +| --- | --- | +| extract | 当前批次没有压缩包 | +| product_detect | 没有任何可用于识别的文件名、目录名或元数据 | + +--- + +## 九、接口详细设计 + +### 9.1 上传暂存接口 + +```text +POST /api/review-agent/conversations/{conversation_id}/attachments/ +Content-Type: multipart/form-data +``` + +请求: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| files[] | File[] | 是 | 一个或多个文件 | + +响应: + +```json +{ + "attachments": [ + { + "id": 101, + "original_name": "注册资料.zip", + "file_size": 204800, + "upload_status": "uploaded" + } + ] +} +``` + +权限: + +```text +conversation.user 必须等于 request.user +``` + +### 9.2 发送消息并按需触发工作流 + +沿用现有 `POST /chat/stream/` SSE 能力,在 `stream_chat` 中增加判断: + +```text +用户发送 prompt +-> 保存 Message +-> 判断 prompt 是否命中自动汇总工作流 +-> 命中则创建 FileSummaryBatch 并启动后台工作流 +-> SSE 返回 workflow_meta +-> 未命中则走原 LLM 流式回复 +``` + +新增 SSE meta: + +```json +{ + "conversation_id": 1, + "title": "新对话", + "workflow": { + "type": "file_summary", + "batch_id": 12, + "status": "running" + } +} +``` + +### 9.3 查询批次状态 + +```text +GET /api/review-agent/file-summary/{batch_id}/ +``` + +响应: + +```json +{ + "batch": { + "id": 12, + "batch_no": "FS202606050001", + "status": "running", + "product_name": "", + "total_files": 24, + "success_files": 10, + "failed_files": 1, + "uncertain_files": 2, + "total_pages": 180 + }, + "nodes": [ + { + "node_code": "page_count", + "node_name": "解析页数中", + "status": "running", + "progress": 45, + "message": "正在解析 11/24" + } + ], + "exports": [] +} +``` + +### 9.4 工作流事件流 + +```text +GET /api/review-agent/file-summary/{batch_id}/events/?after={event_id} +``` + +响应类型:`text/event-stream` + +事件: + +```text +event: node_progress +data: {"event_id": 301, "batch_id": 12, "node_code": "page_count", "status": "running", "progress": 45, "message": "正在解析 11/24"} +``` + +### 9.5 下载导出文件 + +```text +GET /api/review-agent/file-summary/exports/{export_id}/download/ +``` + +权限: + +```text +ExportedSummaryFile -> batch -> conversation -> user 必须为当前用户 +``` + +--- + +## 十、前端详细设计 + +### 10.1 三栏布局 + +页面调整为三栏: + +| 区域 | 内容 | +| --- | --- | +| 左侧栏 | 对话历史 | +| 中间栏 | 聊天消息、输入框 | +| 右侧栏上半部分 | 拖拽式文件导入区 | +| 右侧栏下半部分 | 工作流卡片列表 | + +HTML 结构建议: + +```html +
+ +
+ +
+``` + +### 10.2 上传交互 + +JS 方法: + +```javascript +function bindUploadDropzone() +function uploadConversationFiles(files) +function renderAttachmentList(attachments) +``` + +流程: + +```text +用户拖拽或选择文件 +-> POST attachments 接口 +-> 保存成功后右侧上传区展示文件名 +-> 不启动工作流 +-> 用户发送提示词 +-> 命中工作流后创建工作流卡片 +``` + +### 10.3 工作流卡片 + +JS 方法: + +```javascript +function createWorkflowCard(batch) +function updateWorkflowNode(batchId, nodePayload) +function markWorkflowCompleted(batchId, payload) +function markWorkflowFailed(batchId, payload) +function connectWorkflowEvents(batchId) +function restoreWorkflowCards() +``` + +卡片结构: + +```html +
+
+ 文件目录与页数汇总 + 运行中 +
+
    +
  1. 上传中
  2. +
  3. 解压中
  4. +
  5. 扫描中
  6. +
  7. 解析页数中
  8. +
  9. 识别产品名中
  10. +
  11. 输出 Markdown 中
  12. +
  13. 输出 Excel 中
  14. +
+
+``` + +### 10.4 Markdown 渲染 + +现有消息使用 `nl2br`,无法正常渲染 Markdown 表格。需要改造: + +| 消息类型 | 渲染策略 | +| --- | --- | +| 普通用户消息 | escapeHtml + nl2br | +| 普通助手消息 | 安全 Markdown 渲染 | +| 文件汇总结果 | 安全 Markdown 渲染,允许 table、a、strong、code | + +可选方案: + +| 方案 | 说明 | +| --- | --- | +| 前端 marked + DOMPurify | 渲染体验好,但增加前端依赖 | +| 后端 markdown + bleach | 后端输出安全 HTML,前端直接展示 | + +Demo 建议使用前端 `marked` + `DOMPurify` CDN 或本地静态文件。 + +--- + +## 十一、对话标题更新设计 + +产品名识别成功后更新标题: + +```python +def update_conversation_title(batch, product_name): + conversation = batch.conversation + if conversation.title.startswith("新对话"): + conversation.title = f"{product_name}-文件汇总"[:120] + conversation.save(update_fields=["title", "updated_at"]) +``` + +规则: + +| 场景 | 处理 | +| --- | --- | +| 新对话默认标题 | 更新为产品名 | +| 用户已有自定义标题 | 不覆盖 | +| 产品名为空 | 不更新 | + +--- + +## 十二、测试设计 + +### 12.1 单元测试 + +| 用例 | 目标 | +| --- | --- | +| test_trigger_keywords | 提示词命中时触发自动汇总 | +| test_save_attachment_binds_conversation | 上传文件绑定当前对话 | +| test_zip_extract_safe_path | zip 解压禁止路径穿越 | +| test_scan_files_builds_relative_path | 扫描生成正确相对路径 | +| test_count_pdf_pages | PDF 页数统计 | +| test_count_xlsx_sheets | xlsx 工作表数量统计 | +| test_count_pptx_slides | pptx 幻灯片数量统计 | +| test_retry_three_times | 单文件失败重试 3 次 | +| test_uncertain_old_doc | 老 doc 元数据缺失时标记 uncertain | + +### 12.2 接口测试 + +| 用例 | 目标 | +| --- | --- | +| test_upload_attachment_api | 上传接口返回 attachment_id | +| test_upload_permission_denied | 不能向他人对话上传文件 | +| test_stream_triggers_workflow | 发送命中提示词后返回 workflow meta | +| test_batch_status_permission | 不能查询他人批次 | +| test_export_download_permission | 不能下载他人导出文件 | + +### 12.3 集成测试 + +| 用例 | 目标 | +| --- | --- | +| test_file_summary_zip_workflow | zip 上传后完整工作流成功 | +| test_file_summary_multi_file_workflow | 多文件上传后完整工作流成功 | +| test_single_file_failure_not_blocking | 单文件失败不阻断批次 | +| test_workflow_events_created | 节点事件按顺序写入数据库 | +| test_markdown_and_excel_exports | Markdown 与 Excel 文件生成成功 | + +### 12.4 前端验证 + +| 用例 | 目标 | +| --- | --- | +| 拖拽上传 | 右侧上传区展示文件列表 | +| 提示词触发 | 发送“自动汇总文件目录与页数”后创建工作流卡片 | +| 状态实时更新 | SSE 事件驱动节点状态变化 | +| 页面刷新恢复 | 刷新后右侧卡片恢复当前批次状态 | +| Markdown 表格 | 对话消息中表格和下载链接正常显示 | + +--- + +## 十三、开发顺序 + +1. 增加依赖与模型字段,生成迁移。 +2. 实现文件上传暂存接口和存储目录策略。 +3. 实现 workflow_trigger,根据提示词决定是否启动工作流。 +4. 实现 SkillRegistry、WorkflowExecutor 和 WorkflowEvent。 +5. 实现压缩包解压、文件扫描、页数统计服务。 +6. 实现 Markdown 报告与 Excel 导出。 +7. 改造前端三栏布局、拖拽上传区和工作流卡片。 +8. 增加 Markdown 渲染能力。 +9. 补齐权限测试、工作流测试和前端手工验证。 + +--- + +## 十四、参考依据 + +本设计采用轻量 Python 库优先方案,依据如下: + +| 能力 | 依据 | +| --- | --- | +| PDF 页数 | pypdf 的 PdfReader 可读取 pages | +| docx 元数据 | python-docx 支持 core properties | +| pptx 幻灯片 | python-pptx 可读取 presentation slides | +| xlsx 工作表 | openpyxl 可读取 workbook worksheets | +| xls 工作表 | xlrd 支持读取历史 xls 工作簿 | +| 老 Office 元数据 | olefile 可读取 OLE2 复合文档结构 | +| 7z 解压 | py7zr 支持 7z 压缩格式处理 | +| rar 解压 | rarfile 通常依赖外部 unrar/unar/7z 工具,故本设计优先系统 7z | diff --git a/docs/4.详细设计/2.NMPA注册资料法规核查与整改闭环.md b/docs/4.详细设计/2.NMPA注册资料法规核查与整改闭环.md new file mode 100644 index 0000000..64d3d79 --- /dev/null +++ b/docs/4.详细设计/2.NMPA注册资料法规核查与整改闭环.md @@ -0,0 +1,666 @@ +# NMPA 注册资料法规核查与整改闭环工作流详细设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/2.NMPA注册资料法规核查与整改闭环.md | +| 功能设计文档 | docs/2.功能设计/2.NMPA注册资料法规核查与整改闭环.md | +| 数据库设计文档 | docs/4.数据库设计/2.NMPA注册资料法规核查与整改闭环.md | +| 依赖详细设计 | docs/3.详细设计/1.自动汇总.md | +| 功能名称 | NMPA 注册资料法规核查与整改闭环 | +| 所属模块 | 审核智能体 review_agent | +| 设计日期 | 2026-06-06 | +| 设计版本 | V1.0 | + +--- + +## 一、详细设计目标 + +本详细设计用于指导“NMPA 注册资料法规核查与整改闭环”功能开发落地,覆盖代码结构、通用工作流改造、法规核查执行器、规则/RAG/LLM 调用边界、服务拆分、接口契约、前端交互、飞书 CLI 通知、过程产物留底、异常重试和测试建议。 + +核心约束: + +| 约束 | 说明 | +| --- | --- | +| 复用自动汇总 | 不重复实现上传、解压、扫描和页数统计,法规核查基于 `FileSummaryBatch` 执行 | +| 独立工作流 | 法规核查有独立 `RegulatoryReviewBatch` 和卡片,事件机制与文件汇总共用 | +| 通用事件模型 | `WorkflowNodeRun`、`WorkflowEvent`、`ExportedSummaryFile` 增加 workflow_type 和 workflow_batch_id | +| 异步执行 | 启动接口立即返回 batch_id,后台执行并通过 SSE 更新卡片 | +| 暂停恢复 | 遇到 waiting_user 时后台任务结束,用户确认后重新唤起执行器继续 | +| 规则优先 | 结构化规则负责合规判断,RAG 只补充依据,LLM 只用于低置信度字段抽取和建议润色 | +| 过程留底 | 文本抽取、RAG 结果、LLM 输出、通知和复核记录均生成过程产物 | + +--- + +## 二、代码结构设计 + +### 2.1 目录结构 + +在 `review_agent` 应用内新增 `regulatory_review/` 模块。法规核查与文件汇总并列,通过共享工作流事件和导出服务协同。`review_agent/workflow/` 是对模块 1 中 `file_summary/events.py`、节点状态和导出记录能力的通用化抽取,不是为法规核查重建一套并行事件体系。 + +```text +review_agent/ + models.py + urls.py + views.py + file_summary/ + ... + workflow/ + __init__.py + constants.py + events.py + node_runs.py + exports.py + regulatory_review/ + __init__.py + constants.py + schemas.py + urls.py + views.py + workflow.py + storage.py + services/ + __init__.py + rule_loader.py + rag_citation.py + info_extract.py + text_extract.py + completeness_check.py + structure_check.py + consistency_check.py + risk_assess.py + export.py + feishu_notifier.py + rectification_review.py + condition_parser.py + rules/ + nmpa_ivd_registration_v1.yaml + prompts/ + condition_parse.md + field_extract.md + suggestion_polish.md +``` + +### 2.2 文件职责 + +| 文件 | 职责 | +| --- | --- | +| workflow/constants.py | 通用 workflow_type、节点状态、事件类型 | +| workflow/events.py | 通用 SSE 事件持久化和格式化 | +| workflow/node_runs.py | 通用节点状态创建、更新和恢复 | +| workflow/exports.py | 通用导出记录和下载权限校验 | +| regulatory_review/constants.py | 法规核查节点、风险等级、问题状态常量 | +| regulatory_review/schemas.py | RegulatoryContext、NodeResult、Finding 等 dataclass | +| regulatory_review/workflow.py | RegulatoryWorkflowExecutor,负责编排节点和暂停恢复 | +| regulatory_review/storage.py | 法规核查过程产物路径、hash、文件保存 | +| services/rule_loader.py | 加载规则版本、校验 hash、裁剪适用规则 | +| services/rag_citation.py | 基于 findings 批量检索法规依据 | +| services/info_extract.py | 从文件清单和文本片段抽取适用条件候选值 | +| services/condition_parser.py | 将用户自然语言确认解析为结构化字段 | +| services/text_extract.py | 统一抽取关键文件文本并缓存为 JSON 产物 | +| services/completeness_check.py | 完整性核查,生成 findings | +| services/structure_check.py | 章节结构核查,生成 findings | +| services/consistency_check.py | 跨文件一致性核查,生成 findings | +| services/risk_assess.py | 去重、风险分级、RAG 依据引用、写入 RegulatoryIssue | +| services/export.py | 生成最终报告和过程产物,支持重试 | +| services/feishu_notifier.py | 通过飞书 CLI 发送通知,支持 3 次重试 | +| services/rectification_review.py | 补充资料后的问题复核和状态更新 | + +--- + +## 三、通用工作流改造 + +### 3.1 WorkflowNodeRun 改造 + +现有节点状态表需要兼容多类工作流。 + +| 字段 | 处理 | +| --- | --- | +| batch_id | 保留,兼容文件汇总旧逻辑 | +| workflow_type | 新增,file_summary、regulatory_review | +| workflow_batch_id | 新增,保存对应工作流批次 ID | +| node_group | 新增,可选,用于法规核查卡片主节点聚合 | + +唯一约束调整为: + +```text +unique(workflow_type, workflow_batch_id, node_code) +``` + +文件汇总旧逻辑写入时同步设置: + +```text +workflow_type = file_summary +workflow_batch_id = file_summary_batch.id +batch_id = file_summary_batch.id +``` + +### 3.2 WorkflowEvent 改造 + +事件表同样新增: + +| 字段 | 说明 | +| --- | --- | +| workflow_type | file_summary、regulatory_review | +| workflow_batch_id | 对应工作流批次 ID | +| conversation_id | 冗余记录对话 ID,便于 SSE 查询 | + +SSE 查询时按 `conversation_id` 获取多个工作流事件,前端根据 `workflow_type + workflow_batch_id` 更新对应卡片。 + +### 3.3 ExportedSummaryFile 改造 + +最终下载文件表通用化: + +| 字段 | 说明 | +| --- | --- | +| workflow_type | file_summary、regulatory_review | +| workflow_batch_id | 对应工作流批次 ID | +| export_category | summary_report、risk_report、excel_list、json_package | + +法规核查最终 Markdown、Excel、JSON 结果包进入 `ExportedSummaryFile`;过程产物进入 `RegulatoryArtifact`。 + +--- + +## 四、核心数据结构 + +### 4.1 RegulatoryContext + +节点间传递统一上下文,避免每个服务重复组装状态。 + +```python +@dataclass +class RegulatoryContext: + regulatory_batch: RegulatoryReviewBatch + file_summary_batch: FileSummaryBatch | None + rule_version: RegulatoryRuleVersion | None + rules: dict[str, Any] + scoped_rules: list[dict[str, Any]] + conditions: dict[str, Any] + file_items: list[FileSummaryItem] + text_artifacts: dict[str, Any] + findings: list["Finding"] + issues: list[RegulatoryIssue] + artifacts: list[RegulatoryArtifact] + reference_only: bool = False +``` + +### 4.2 NodeResult + +每个节点统一返回 `NodeResult`。 + +```python +@dataclass +class NodeResult: + status: str + message: str = "" + payload: dict[str, Any] = field(default_factory=dict) + findings: list["Finding"] = field(default_factory=list) + artifacts: list[RegulatoryArtifact] = field(default_factory=list) + next_node: str | None = None +``` + +### 4.3 Finding + +核查服务只返回 findings,不直接写 `RegulatoryIssue`。Issue 由 `RiskAssessService` 统一去重、分级和落库。 + +```python +@dataclass +class Finding: + finding_key: str + issue_type: str + initial_risk_level: str + title: str + description: str + rule_id: str | None = None + file_item_id: int | None = None + file_path: str | None = None + page_no: int | None = None + field_name: str | None = None + evidence: dict[str, Any] = field(default_factory=dict) + suggestion_template: str | None = None + source_node: str | None = None +``` + +--- + +## 五、工作流执行设计 + +### 5.1 启动流程 + +```text +POST /regulatory-review/start/ +-> 创建 RegulatoryReviewBatch(status=pending) +-> 查找当前对话最近一次 success FileSummaryBatch +-> 如有则绑定并异步启动法规核查 +-> 如无则创建 FileSummaryBatch 并启动自动汇总 +-> 自动汇总 success 后回填 file_summary_batch_id +-> 继续法规核查 prepare 节点 +``` + +如果用户明确说“重新核查最新上传资料”,系统强制创建新的 `FileSummaryBatch`,再创建新的 `RegulatoryReviewBatch`。 + +### 5.2 暂停与恢复 + +当适用条件缺失或解析冲突时: + +```text +RegulatoryWorkflowExecutor +-> 写入 condition_confirm 节点 status=waiting_user +-> RegulatoryReviewBatch.status=waiting_user +-> 发送 workflow SSE +-> 后台任务结束 +``` + +用户确认后: + +```text +POST /regulatory-review/{batch_id}/confirm-condition/ +-> LLM 解析自然语言为结构化 JSON +-> 字段校验器校验必填字段 +-> 如仍缺失,继续追问并保持 waiting_user +-> 如完整,写入 batch 核心字段和 condition_json +-> 重新唤起 RegulatoryWorkflowExecutor,从 rule_scope 节点继续 +``` + +### 5.3 节点调度 + +```text +prepare +-> info_extract +-> condition_confirm 或 rule_scope +-> rule_scope +-> completeness_check +-> text_extract +-> 并行执行 structure_check 和 consistency_check +-> risk_assess +-> report_export +-> notify +-> completed +``` + +章节核查和一致性核查通过后台线程池并行: + +```python +with ThreadPoolExecutor(max_workers=2) as pool: + structure_future = pool.submit(structure_service.run, context) + consistency_future = pool.submit(consistency_service.run, context) +``` + +### 5.4 关键节点 + +关键节点失败时终止批次: + +| 节点 | 失败处理 | +| --- | --- | +| prepare | 无法绑定文件汇总批次,批次 failed | +| rule_scope | 规则 hash 不一致,批次 failed;规则加载失败可降级 reference_only | +| report_export | 最终报告重试失败,批次 failed | + +非关键节点失败时生成 `Finding` 或 `RegulatoryIssue`,工作流尽量继续: + +| 节点 | 失败处理 | +| --- | --- | +| text_extract | 对相关文件生成待确认 finding | +| structure_check | 生成章节核查失败 finding | +| consistency_check | 生成一致性待确认 finding | +| notify | 写通知失败记录,批次可 partial_success | + +--- + +## 六、规则、RAG 与 LLM 设计 + +### 6.1 RuleLoader + +流程: + +```text +读取当前 active RegulatoryRuleVersion +-> 读取 rule_file_path +-> 计算文件 hash +-> 与 rule_file_hash 比对 +-> hash 一致则解析规则 +-> 按适用条件裁剪 scoped_rules +``` + +处理策略: + +| 场景 | 处理 | +| --- | --- | +| 规则文件 hash 不一致 | 停止执行并标记 failed | +| 规则文件不存在或解析失败 | 降级 RAG 辅助核查,batch.status=reference_only | +| RAG 索引版本缺失 | 记录提示项,但规则核查可继续 | + +### 6.2 RagCitationService + +RAG 在 `RiskAssessService` 阶段批量调用,而不是每个核查节点实时调用。 + +输入: + +| 字段 | 说明 | +| --- | --- | +| findings | 所有核查 findings | +| rule_version | 当前法规规则版本 | +| scoped_rules | 本次适用规则 | + +输出: + +| 字段 | 说明 | +| --- | --- | +| citations_by_finding | finding_key 到法规依据列表的映射 | +| rag_result_json | RAG 检索结果过程产物 | + +### 6.3 LLM 调用边界 + +| 场景 | 是否调用 LLM | 说明 | +| --- | --- | --- | +| 自然语言适用条件解析 | 是 | 解析为结构化 JSON,再由字段校验器校验 | +| 低置信度字段抽取 | 是 | 规则/正则失败或置信度低时调用 | +| 整改建议润色 | 是 | 规则模板生成标准动作,LLM 润色表达 | +| 风险等级判断 | 否 | 风险等级由规则和 RiskAssess 决定 | +| 法规结论判断 | 否 | 合规判断不交给 LLM | + +LLM 抽取结果需写入过程产物,可使用 `llm_extract_json` 或并入 `text_extract_json`。 + +--- + +## 七、服务详细设计 + +### 7.1 RegulatoryWorkflowExecutor + +| 方法 | 说明 | +| --- | --- | +| start(batch_id) | 创建后台任务并返回 | +| run(batch_id, start_node=None) | 运行法规核查节点 | +| build_context(batch_id) | 组装 RegulatoryContext | +| run_node(node_code, context) | 执行单个节点并处理 NodeResult | +| run_parallel_checks(context) | 并行执行章节和一致性核查 | +| pause_for_user(batch, node_code, message) | 写 waiting_user 状态并结束任务 | +| complete(batch) | 标记批次完成 | +| fail(batch, error) | 标记批次失败 | + +### 7.2 ConditionParserService + +| 方法 | 说明 | +| --- | --- | +| parse(raw_user_input, previous_conditions) | 使用 LLM 解析自然语言 | +| validate(parsed_json) | 校验产品类别、注册类型、临床路径、产品名称、型号规格、预期用途 | +| merge(batch, parsed_json) | 写入批次字段和 condition_json | + +### 7.3 RiskAssessService + +| 方法 | 说明 | +| --- | --- | +| deduplicate(findings) | 按 finding_key、rule_id、file_item_id 去重 | +| attach_citations(findings) | 批量调用 RAG 获取法规依据 | +| resolve_risk(finding) | 统一风险等级,处理升级/降级 | +| generate_suggestion(finding) | 规则模板 + LLM 润色 | +| create_issues(batch, findings) | 统一写入 RegulatoryIssue | +| build_risk_summary(batch) | 写入 risk_summary_json | + +### 7.4 RegulatoryExportService + +| 方法 | 说明 | +| --- | --- | +| export_final_markdown(batch) | 生成最终 Markdown 核查报告 | +| export_final_excel(batch) | 生成 Excel 缺失清单 | +| export_json_package(batch) | 生成结构化 JSON 结果包 | +| create_artifact(batch, artifact_type, path) | 写 RegulatoryArtifact 并计算 hash | +| create_export_record(batch, path, category) | 写 ExportedSummaryFile | +| retry_export(fn, max_retry=3) | 导出失败重试 | + +重试策略: + +| 产物 | 重试后仍失败 | +| --- | --- | +| 最终 Markdown/Excel/JSON | 批次 failed | +| 非关键过程产物 | 批次 partial_success | + +### 7.5 FeishuNotifier + +调用方式必须使用参数数组,不拼接 shell 字符串。 + +```python +subprocess.run( + [cli_path, "send", "--user", feishu_user_id, "--message", message], + check=True, + capture_output=True, + text=True, +) +``` + +处理策略: + +| 场景 | 处理 | +| --- | --- | +| 用户无 feishu_user_id | 写通知失败记录,不阻断 | +| CLI 执行失败 | 最多重试 3 次 | +| 仍失败 | send_status=failed,批次可 partial_success | +| 成功 | 写 external_message_id 和 sent_at | + +通知内容包含系统内风险报告链接,不附原始文件。 + +--- + +## 八、接口详细设计 + +### 8.1 发起法规核查 + +| 项目 | 内容 | +| --- | --- | +| URL | POST /api/review-agent/regulatory-review/start/ | +| 请求 | conversation_id、file_summary_batch_id 可选、force_resummary 可选 | +| 响应 | regulatory_batch_id、workflow_type、status | + +响应示例: + +```json +{ + "regulatory_batch_id": 2001, + "workflow_type": "regulatory_review", + "status": "pending" +} +``` + +### 8.2 确认适用条件 + +| 项目 | 内容 | +| --- | --- | +| URL | POST /api/review-agent/regulatory-review/{batch_id}/confirm-condition/ | +| 请求 | raw_user_input、可选结构化字段 | +| 响应 | status、missing_fields、next_question | + +如果解析完整: + +```json +{ + "status": "accepted", + "next_node": "rule_scope" +} +``` + +如果仍缺失: + +```json +{ + "status": "need_more_info", + "missing_fields": ["clinical_evaluation_path"], + "next_question": "请确认临床评价路径:临床试验、免临床,还是同品种比对?" +} +``` + +### 8.3 查询状态 + +| 项目 | 内容 | +| --- | --- | +| URL | GET /api/review-agent/regulatory-review/{batch_id}/ | +| 响应 | 批次、节点、风险摘要、导出文件、过程产物 | + +### 8.4 发起整改复核 + +| 项目 | 内容 | +| --- | --- | +| URL | POST /api/review-agent/regulatory-review/{batch_id}/rectify-review/ | +| 请求 | issue_ids、file_summary_batch_id 或 uploaded_file_ids | +| 响应 | review_status、updated_issues、review_artifact_id | + +补充文件必须复用自动汇总上传与汇总能力。上传后先生成新的 `FileSummaryBatch`,再由 `RectificationReviewService` 对原批次问题执行复核。复核不创建新的 `RegulatoryReviewBatch`。 + +--- + +## 九、前端与对话交互 + +### 9.1 工作流卡片 + +| 设计点 | 说明 | +| --- | --- | +| 卡片切换 | 多工作流卡片使用轮播切换 | +| 卡片识别 | 使用 workflow_type + workflow_batch_id | +| 状态来源 | SSE workflow 事件 | +| 法规卡片 | 展示主节点和可展开子节点 | +| waiting_user | 卡片显示等待确认,对话框给出选择和追问 | + +### 9.2 自然语言确认 + +对话框中用户可以用自然语言确认,例如: + +```text +按体外诊断试剂首次注册处理,临床评价路径走同品种比对,产品名称是 XXX,型号规格是 YYY,预期用途是 ZZZ。 +``` + +后端解析并校验后继续工作流。原始输入写入 `condition_json.raw_user_input`。 + +### 9.3 整改复核触发 + +Demo 阶段通过对话指令触发: + +```text +我已补充注册检验报告,请复核阻断项。 +``` + +系统识别后调用复核接口,要求用户上传补充文件或选择已上传文件。 + +--- + +## 十、过程产物与报告 + +### 10.1 文件命名 + +过程产物和最终报告采用固定模板: + +```text +{batch_no}_{artifact_type}.{ext} +``` + +示例: + +```text +RRB202606060001_rule_matrix.xlsx +RRB202606060001_risk_list.json +RRB202606060001_final_report.md +``` + +### 10.2 文件保存 + +路径: + +```text +media/regulatory_review/{user_id}/{conversation_id}/{batch_id}/ +``` + +所有 `RegulatoryArtifact` 必须计算 SHA-256 hash。 + +### 10.3 报告内容 + +最终 Markdown 报告包含: + +| 模块 | 说明 | +| --- | --- | +| 核查概览 | 批次、规则版本、RAG 版本、上传人 | +| 适用条件 | 系统抽取和用户确认结果 | +| 风险清单 | 五级风险、状态、责任人、建议 | +| 法规核查矩阵 | 应有文件、实际文件、缺失情况 | +| 章节核查结果 | 缺失章节、异常章节 | +| 一致性核查结果 | 字段冲突和来源文件 | +| 飞书通知记录 | 发送对象、状态、失败原因 | +| 整改复核记录 | 复核方式、复核结果、关闭确认 | + +--- + +## 十一、异常与重试 + +| 场景 | 处理 | +| --- | --- | +| 无成功 FileSummaryBatch | 自动启动文件汇总,成功后继续 | +| 文件汇总失败 | 法规核查批次 failed | +| 规则 hash 不一致 | 法规核查批次 failed | +| 规则加载失败 | 降级 reference_only,仅输出参考性结果 | +| 用户确认信息缺失 | waiting_user,追问缺失字段 | +| 文本抽取失败 | 生成待确认 finding,继续后续节点 | +| 章节或一致性节点失败 | 生成对应 issue,继续风险汇总 | +| RAG 检索无结果 | 规则问题仍输出,依据标记原文待补充 | +| LLM 调用失败 | 回退规则/正则结果,低置信度项待确认 | +| 飞书失败 | 重试 3 次,仍失败写通知失败记录 | +| 最终报告导出失败 | 重试 3 次,仍失败 batch failed | +| 非关键产物导出失败 | 重试 3 次,仍失败 batch partial_success | + +--- + +## 十二、测试建议 + +### 12.1 单元测试 + +| 模块 | 测试点 | +| --- | --- | +| RuleLoader | hash 校验、规则解析、规则裁剪、加载失败降级 | +| ConditionParserService | 自然语言解析、缺失字段追问、原始输入留痕 | +| TextExtractService | 首页文本、章节文本、抽取失败产物 | +| CompletenessCheckService | 文件名/目录名/首页内容三层匹配 | +| StructureCheckService | 必需章节缺失识别 | +| ConsistencyCheckService | 字段冲突、低置信度 LLM 辅助 | +| RiskAssessService | findings 去重、风险升级/降级、Issue 落库 | +| RegulatoryExportService | 文件命名、hash、导出重试 | +| FeishuNotifier | 参数数组调用、3 次重试、失败记录 | + +### 12.2 集成测试 + +| 场景 | 验证 | +| --- | --- | +| 已有汇总批次发起核查 | 默认复用最近 success 批次 | +| 无汇总批次发起核查 | 自动串联文件汇总后继续 | +| waiting_user 暂停恢复 | 用户确认后从 rule_scope 继续 | +| 章节和一致性并行 | 两个节点均完成后进入 risk_assess | +| 规则加载失败 | batch.status=reference_only | +| 飞书失败 | 不阻断报告,通知记录 failed | +| 补充文件复核 | 新 FileSummaryBatch 生成,原 Issue 状态更新 | + +### 12.3 验收测试 + +| 序号 | 验收项 | 标准 | +| --- | --- | --- | +| 1 | 多工作流卡片 | 文件汇总和法规核查卡片可切换且状态独立 | +| 2 | 条件确认 | 用户自然语言确认后能结构化入库 | +| 3 | 完整性核查 | 能识别缺失注册检验报告等问题 | +| 4 | 章节核查 | 能识别关键章节缺失 | +| 5 | 一致性核查 | 能识别产品名称、型号规格、预期用途冲突 | +| 6 | 风险报告 | 输出 Markdown、Excel、JSON 结果包 | +| 7 | 飞书通知 | 阻断项、高风险、中风险能 @ 上传人 | +| 8 | 过程留底 | RAG、文本抽取、通知、复核均有 artifact | +| 9 | 整改复核 | 补充文件后原 Issue 可进入复核通过或复核不通过 | + +--- + +## 十三、实施顺序建议 + +结合当前优先级,建议先打通 RAG 和 LLM 能力,再落完整工作流: + +1. 构建本地法规材料 RAG 索引,并实现 `RagCitationService`。 +2. 实现适用条件解析和低置信度字段抽取的 LLM 调用封装。 +3. 完成数据库模型和通用 workflow/export 表改造。 +4. 实现 `RuleLoader` 与规则 hash 校验。 +5. 实现 `RegulatoryWorkflowExecutor`、`RegulatoryContext`、`NodeResult`。 +6. 实现完整性、文本抽取、章节核查、一致性核查和风险归并。 +7. 实现报告导出、过程产物 hash 和导出重试。 +8. 接入飞书 CLI 通知和 3 次重试。 +9. 改造前端多工作流卡片和适用条件确认交互。 +10. 实现整改复核和 Issue 状态流转。 diff --git a/docs/4.详细设计/3.产品关键信息提取与申报文件自动填表.md b/docs/4.详细设计/3.产品关键信息提取与申报文件自动填表.md new file mode 100644 index 0000000..fa88fe7 --- /dev/null +++ b/docs/4.详细设计/3.产品关键信息提取与申报文件自动填表.md @@ -0,0 +1,790 @@ +# 产品关键信息提取与申报文件自动填表详细设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/3.产品关键信息提取与申报文件自动填表.md | +| 功能设计文档 | docs/2.功能设计/3.产品关键信息提取与申报文件自动填表.md | +| 数据库设计文档 | docs/4.数据库设计/3.产品关键信息提取与申报文件自动填表.md | +| 依赖详细设计 | docs/3.详细设计/1.自动汇总.md;docs/3.详细设计/2.NMPA注册资料法规核查与整改闭环.md | +| 功能名称 | 产品关键信息提取与申报文件自动填表 | +| 所属模块 | 审核智能体 review_agent | +| 设计日期 | 2026-06-07 | +| 设计版本 | V1.0 | + +--- + +## 一、详细设计目标 + +本详细设计用于指导“产品关键信息提取与申报文件自动填表”功能开发落地,覆盖代码结构、数据库模型、模板配置、独立工作流、字段抽取、字段合并、Word 模板填充、追溯清单导出、飞书通知、接口契约、前端卡片、异常降级和测试建议。 + +核心约束: + +| 约束 | 说明 | +| --- | --- | +| 独立工作流 | 使用 `workflow_type=application_form_fill`,拥有独立批次和卡片 | +| 对话触发 | 由用户自然语言触发,可指定模板;未指定时按注册类型选择适用模板 | +| 文件来源复用 | 默认使用当前对话最近成功的 `FileSummaryBatch`;本次带附件时先执行自动汇总 | +| 模板配置驱动 | 模板路径、字段映射、适用条件写入 `application_form_fill/templates/application_form_templates_v1.yaml` | +| Word 优先 | Demo 阶段主链路只要求生成 Word 和追溯清单 | +| PDF 待办 | PDF 转换节点保留,但本期可标记 skipped 并写入待办计划 | +| 抽取并行 | 规则/正则抽取与 LLM 结构化抽取并行执行,再统一合并 | +| 冲突可见 | 说明书优先;冲突字段写入 Word 时黄底红字,并在对话框展示摘要 | +| 过程留底 | 规则抽取、LLM 抽取、合并结果、冲突和追溯清单均保存产物 | +| 飞书通知 | 填表完成后通知上传人,通知失败不阻断下载 | + +--- + +## 二、代码结构设计 + +### 2.1 目录结构 + +第三批独立为 `review_agent/application_form_fill/` 模块。Django 模型仍集中在 `review_agent/models.py`,业务服务放入独立模块。 + +```text +review_agent/ + models.py + services.py + skill_router.py + application_form_fill/ + __init__.py + constants.py + schemas.py + storage.py + workflow.py + views.py + services/ + __init__.py + template_config.py + template_select.py + template_repository.py + field_extract.py + field_merge.py + word_fill.py + traceability_export.py + notifier.py + templates/ + application_form_templates_v1.yaml + prompts/ + field_extract.md + checklist_extract.md +``` + +### 2.2 文件职责 + +| 文件 | 职责 | +| --- | --- | +| application_form_fill/constants.py | 工作流节点、模板编码、状态、输出类型常量 | +| application_form_fill/schemas.py | FormFillContext、TemplateSpec、ExtractedField、MergedField 等 dataclass | +| application_form_fill/storage.py | 批次工作目录、模板副本、产物保存、hash 计算 | +| application_form_fill/workflow.py | FormFillWorkflowExecutor,串行执行独立填表工作流 | +| application_form_fill/views.py | 启动、状态查询、后续可选下载或重试接口 | +| services/template_config.py | 读取和校验 YAML 模板配置 | +| services/template_select.py | 解析用户指定模板、识别注册类型、选择模板 | +| services/template_repository.py | 定位原始模板、复制模板、`.doc` 转 `.docx` 预留 | +| services/field_extract.py | 规则/正则与 LLM 并行字段抽取 | +| services/field_merge.py | 字段归一化、来源排序、冲突识别、最终字段输出 | +| services/word_fill.py | 使用 `python-docx` 写入 Word 表格、段落和高亮 | +| services/traceability_export.py | 生成 Excel/JSON 追溯清单,创建导出记录 | +| services/notifier.py | 包装飞书通知,生成通知记录 | +| prompts/field_extract.md | LLM 字段抽取提示词 | +| prompts/checklist_extract.md | 安全和性能基本原则清单条目判断提示词 | + +--- + +## 三、依赖设计 + +### 3.1 Python 依赖 + +| 依赖 | 用途 | 当前项目情况 | +| --- | --- | --- | +| Django | Web、ORM、权限 | 已使用 | +| python-docx | Word 模板读取、表格填充、字体和底色设置 | 已在项目依赖链中使用 | +| openpyxl | 字段来源追溯清单 Excel 导出 | 已使用 | +| PyYAML | YAML 模板配置读取 | 已用于法规规则 | +| pypdf / python-pptx | 文本抽取链路复用 | 已使用 | +| LibreOffice/soffice | `.doc` 转 `.docx`、PDF 转换 | 本期非强依赖,后续待办 | + +### 3.2 技术边界 + +| 能力 | 本期实现 | 后续增强 | +| --- | --- | --- | +| `.docx` 模板填充 | 必须支持 | 支持内容控件、复杂 OOXML patch | +| `.doc` 模板处理 | 可通过预转换模板或标记失败 | 自动 LibreOffice 转换 | +| PDF 转换 | 可跳过并提示待生成 | LibreOffice 转 PDF + 视觉 QA | +| 字段级入库 | 不做 | 新增字段明细表和在线编辑 | +| LLM 抽取 | 输出 JSON 并留底 | 增加置信度校准和人工确认 | + +--- + +## 四、数据模型详细设计 + +模型放在 `review_agent/models.py`。 + +### 4.1 ApplicationFormFillBatch + +```python +class ApplicationFormFillBatch(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "待执行" + RUNNING = "running", "执行中" + WAITING_USER = "waiting_user", "等待用户" + SUCCESS = "success", "成功" + PARTIAL_SUCCESS = "partial_success", "部分成功" + FAILED = "failed", "失败" + CANCELLED = "cancelled", "已取消" +``` + +关键字段: + +| 字段 | 说明 | +| --- | --- | +| conversation | 绑定对话 | +| user | 发起用户 | +| trigger_message | 触发消息 | +| source_summary_batch | 文件来源批次 | +| source_regulatory_batch | 可选法规核查批次 | +| batch_no | `AFF-YYYYMMDDHHMMSS-abcdef` | +| requested_templates | 用户指定模板 | +| selected_templates | 实际生成模板 | +| output_types | 本次请求输出类型,Demo 默认 `["word", "excel", "json"]` | +| registration_type | 识别出的注册类型 | +| registration_type_source | 注册类型来源 | +| product_name | 产品名称 | +| conflict_summary | 冲突摘要 | +| risk_notes | 不适用模板、PDF 待生成等提示 | +| template_config_version | 模板配置版本 | +| template_config_hash | 模板配置 hash | +| work_dir | 批次工作目录 | + +### 4.2 ApplicationFormFillArtifact + +用于保存过程产物和模板副本元数据。 + +```python +class ApplicationFormFillArtifact(models.Model): + class ArtifactType(models.TextChoices): + TEMPLATE_COPY = "template_copy", "模板副本" + FIELD_EXTRACT_RESULT = "field_extract_result", "字段抽取结果" + MERGED_FIELDS = "merged_fields", "字段合并结果" + TRACEABILITY = "traceability", "追溯清单" + FILLED_TEMPLATE = "filled_template", "已填模板" + NOTIFICATION_RECORD = "notification_record", "通知记录" +``` + +### 4.3 ApplicationFormFillNotificationRecord + +通知记录字段与第二批法规通知风格一致,支持重试: + +| 字段 | 说明 | +| --- | --- | +| batch | 自动填表批次 | +| recipient | 通知对象 | +| channel | feishu_cli、feishu_api、mock | +| template_codes | 涉及模板 | +| export_ids | 关联下载文件 | +| message_summary | 通知摘要 | +| send_status | pending、success、failed | +| retry_count | 重试次数 | +| external_message_id | 飞书外部消息 ID | +| error_message | 失败原因 | +| sent_at | 发送成功时间 | + +### 4.4 ExportedSummaryFile 扩展 + +`ExportedSummaryFile.ExportType` 增加: + +```python +WORD = "word", "Word" +PDF = "pdf", "PDF" +``` + +填表导出记录使用: + +| 字段 | 值 | +| --- | --- | +| workflow_type | application_form_fill | +| workflow_batch_id | ApplicationFormFillBatch.id | +| export_category | filled_template、traceability、extract_result | +| export_type | word、excel、json、pdf | + +--- + +## 五、常量设计 + +### 5.1 工作流节点 + +```python +FORM_FILL_NODE_DEFINITIONS = [ + ("prepare", "准备资料", "form_fill"), + ("template_select", "选择模板", "form_fill"), + ("template_copy", "复制模板", "form_fill"), + ("field_extract", "抽取字段", "form_fill"), + ("conflict_merge", "冲突归并", "form_fill"), + ("word_fill", "填写 Word", "form_fill"), + ("pdf_convert", "转换 PDF", "form_fill"), + ("trace_export", "追溯清单", "form_fill"), + ("output_export", "输出下载", "form_fill"), + ("notify", "飞书通知", "form_fill"), + ("completed", "完成", "completed"), +] +``` + +### 5.2 模板编码 + +```python +TEMPLATE_REGISTRATION_CERTIFICATE = "registration_certificate" +TEMPLATE_CHANGE_REGISTRATION = "change_registration" +TEMPLATE_ESSENTIAL_PRINCIPLES = "essential_principles" +``` + +### 5.3 触发关键词 + +```python +FORM_FILL_TRIGGER_KEYWORDS = [ + "填注册证", + "对应的表格", + "生成申报模板", + "安全和性能基本原则清单", + "填到申报模板", + "自动填表", + "生成表格", +] +``` + +--- + +## 六、核心数据结构 + +### 6.1 FormFillContext + +```python +@dataclass +class FormFillContext: + batch: ApplicationFormFillBatch + source_summary_batch: FileSummaryBatch + source_regulatory_batch: RegulatoryReviewBatch | None + template_config: dict[str, Any] + selected_templates: list["TemplateSpec"] + document_texts: dict[str, str] + regex_results: dict[str, Any] + llm_results: dict[str, Any] + merged_fields: dict[str, "MergedField"] + checklist_items: dict[str, Any] + conflicts: list[dict[str, Any]] + exports: list[ExportedSummaryFile] +``` + +### 6.2 TemplateSpec + +```python +@dataclass(frozen=True) +class TemplateSpec: + code: str + name: str + source_file: str + output_label: str + applies_when: dict[str, Any] + file_format: str + fields: list[dict[str, Any]] + checklist_items: list[dict[str, Any]] +``` + +### 6.3 ExtractedField + +```python +@dataclass(frozen=True) +class ExtractedField: + key: str + label: str + value: str + source_file: str + source_role: str + evidence: str + extractor: str + confidence: float +``` + +### 6.4 MergedField + +```python +@dataclass(frozen=True) +class MergedField: + key: str + label: str + value: str + source_file: str + evidence: str + confidence: float + has_conflict: bool = False + conflict_values: list[dict[str, Any]] = field(default_factory=list) +``` + +--- + +## 七、模板配置详细设计 + +### 7.1 配置路径 + +```text +review_agent/application_form_fill/templates/application_form_templates_v1.yaml +``` + +### 7.2 初始配置示例 + +```yaml +version: application_form_templates_v1 +source_dir: docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告 +templates: + - code: registration_certificate + name: 中华人民共和国医疗器械注册证(体外诊断试剂)(格式) + source_file: 中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx + output_label: 注册证格式 + applies_when: + registration_type: ["首次注册"] + file_format: docx + fields: + - key: applicant_name + label: 注册人名称 + target: + type: table_row + row_label: 注册人名称 + source_roles: ["申请表", "说明书", "企业信息"] + - key: product_name + label: 产品名称 + target: + type: table_row + row_label: 产品名称 + source_roles: ["说明书", "产品技术要求", "注册检验报告"] + - key: intended_use + label: 预期用途 + target: + type: table_row + row_label: 预期用途 + source_roles: ["说明书", "临床评价资料", "产品技术要求"] +``` + +### 7.3 配置校验 + +`TemplateConfigService` 启动时校验: + +| 校验项 | 失败处理 | +| --- | --- | +| version 存在 | 批次 failed | +| source_dir 存在 | 批次 failed | +| templates 非空 | 批次 failed | +| code 唯一 | 批次 failed | +| source_file 存在 | 对应模板不可用 | +| target.type 支持 | 对应字段跳过并记录 | + +--- + +## 八、服务详细设计 + +### 8.1 TemplateConfigService + +```python +def load_template_config() -> dict: + """读取 YAML 模板配置。""" + +def validate_template_config(config: dict) -> list[str]: + """返回配置错误列表。""" + +def compute_config_hash(path: Path) -> str: + """计算模板配置 SHA-256。""" +``` + +### 8.2 TemplateSelectionService + +```python +def parse_requested_templates(message: str) -> list[str]: + """从用户话语中识别指定模板。""" + +def detect_registration_type(batch: ApplicationFormFillBatch, message: str) -> tuple[str, str]: + """按用户话语、法规核查批次、文件抽取结果识别注册类型及来源。""" + +def select_templates( + config: dict, + requested_templates: list[str], + registration_type: str, +) -> tuple[list[TemplateSpec], list[dict]]: + """输出模板列表和风险提示。""" +``` + +注册类型优先级: + +```text +用户话语明确指定 +-> source_regulatory_batch.condition_json / confirmed_conditions +-> source_summary_batch 文件内容抽取候选 +-> unknown +``` + +### 8.3 TemplateRepository + +```python +def resolve_source_template(spec: TemplateSpec) -> Path: + """返回原始模板路径或预转换工作模板路径。""" + +def copy_template_to_batch(spec: TemplateSpec, batch: ApplicationFormFillBatch) -> Path: + """复制模板到批次 work_dir/templates。""" + +def convert_doc_to_docx(source: Path, target_dir: Path) -> Path: + """P1 能力:使用 soffice 转 docx。""" +``` + +`.doc` 模板本期处理: + +| 场景 | 处理 | +| --- | --- | +| 存在 working_template docx | 使用工作模板 | +| 仅有 `.doc` 且无 soffice | 对应模板失败,其他模板继续 | +| 具备 soffice | 转换为 `.docx` 后继续 | + +### 8.4 FieldExtractionService + +```python +def collect_document_texts(summary_batch: FileSummaryBatch) -> dict[str, str]: + """复用 text_extract 读取文件文本。""" + +def extract_by_rules(texts: dict[str, str], specs: list[TemplateSpec]) -> dict: + """规则/正则抽取字段。""" + +def extract_by_llm(texts: dict[str, str], specs: list[TemplateSpec]) -> dict: + """LLM 结构化抽取字段。""" + +def run_parallel_extract(texts: dict[str, str], specs: list[TemplateSpec]) -> tuple[dict, dict]: + """并行执行规则/正则与 LLM 抽取。""" +``` + +并行实现可使用 `ThreadPoolExecutor(max_workers=2)`。LLM 超时或失败时,保留规则/正则结果继续。 + +### 8.5 FieldMergeService + +```python +def normalize_field_value(value: str) -> str: + """字段值归一化。""" + +def rank_source(source_role: str, source_file: str) -> int: + """说明书优先,其次产品技术要求、检测报告、性能研究等。""" + +def merge_fields(regex_results: dict, llm_results: dict) -> tuple[dict[str, MergedField], list[dict]]: + """合并字段并输出冲突。""" +``` + +来源优先级: + +| 排名 | 来源 | +| --- | --- | +| 1 | 说明书 | +| 2 | 产品技术要求 | +| 3 | 注册检验报告/检测报告 | +| 4 | 性能研究资料 | +| 5 | 其他注册资料 | + +### 8.6 WordTemplateFillService + +```python +def fill_template( + template_path: Path, + output_path: Path, + spec: TemplateSpec, + fields: dict[str, MergedField], + checklist_items: dict[str, Any], +) -> Path: + """填充 Word 模板并保存。""" + +def fill_table_row(document: Document, row_label: str, value: str, conflict: bool) -> bool: + """根据表格行首字段名定位并填入第二列。""" + +def replace_placeholders(document: Document, fields: dict[str, MergedField]) -> None: + """替换段落中的 {{field_key}}。""" + +def apply_conflict_style(cell_or_run) -> None: + """应用黄色底色和红色字体。""" +``` + +冲突样式: + +| 样式 | 说明 | +| --- | --- | +| 字体颜色 | 红色 `FF0000` | +| 底色 | 黄色 `FFFF00` | +| 适用范围 | 单元格或字段值 run | + +### 8.7 TraceabilityExportService + +```python +def build_traceability_workbook(batch, merged_fields, conflicts, specs) -> Workbook: + """生成追溯清单 Excel。""" + +def save_traceability_excel(batch, workbook) -> ExportedSummaryFile: + """保存 Excel 并写导出记录。""" + +def save_extract_json(batch, payload: dict) -> ApplicationFormFillArtifact: + """保存字段抽取 JSON 过程产物。""" +``` + +Excel Sheet: + +| Sheet | 内容 | +| --- | --- | +| 字段追溯 | 模板、字段、填入值、来源文件、证据、冲突状态 | +| 冲突字段 | 字段、采用值、冲突值、处理方式 | +| 低置信度条目 | 安全和性能基本原则清单候选判断 | +| 生成结果 | 模板文件、Word 状态、PDF 状态、错误说明 | + +### 8.8 FormFillNotifier + +```python +def notify_completion(batch: ApplicationFormFillBatch, exports: list[ExportedSummaryFile]) -> ApplicationFormFillNotificationRecord: + """发送填表完成通知。""" +``` + +通知摘要包含: + +| 内容 | 说明 | +| --- | --- | +| 批次号 | 填表批次 | +| 产品名称 | 如已识别 | +| 生成模板 | 模板名称列表 | +| 冲突数量 | 提示需下载核对 | +| 下载提示 | 提示回到系统对话下载,不直接暴露敏感全文 | + +--- + +## 九、工作流执行器详细设计 + +### 9.1 启动入口 + +```python +def start_application_form_fill_workflow(batch: ApplicationFormFillBatch, *, async_run: bool = True) -> None: + executor = FormFillWorkflowExecutor(batch) + if async_run: + Thread(target=executor.run, daemon=True).start() + else: + executor.run() +``` + +### 9.2 执行伪代码 + +```python +class FormFillWorkflowExecutor: + def run(self) -> None: + self.mark_batch_running() + try: + for node in self.nodes(): + if node.status == "success": + continue + self.run_node(node) + self.complete_or_partial() + except WorkflowPausedForUser: + self.mark_waiting_user() + except Exception as exc: + self.mark_failed(exc) +``` + +### 9.3 节点处理要点 + +| 节点 | 处理 | +| --- | --- | +| prepare | 校验 `source_summary_batch` 成功且属于当前对话 | +| template_select | 读取 YAML、识别注册类型、选择模板 | +| template_copy | 复制模板到 `work_dir/templates` | +| field_extract | 抽取文本,规则/正则与 LLM 并行,保存 JSON | +| conflict_merge | 合并字段,写 `conflict_summary` | +| word_fill | 逐模板生成 Word,写 `ExportedSummaryFile(word)` | +| pdf_convert | 本期 skipped,写 `risk_notes` | +| trace_export | 生成追溯 Excel 和 JSON | +| output_export | 生成 AI 对话 Markdown 摘要 | +| notify | 写飞书通知记录,失败不阻断 | +| completed | 标记 success 或 partial_success | + +### 9.4 批次状态决策 + +| 条件 | 状态 | +| --- | --- | +| 所有目标 Word 均成功,追溯清单成功,通知成功或跳过 | success | +| 至少一个 Word 成功,但部分模板、追溯清单、PDF 或通知失败 | partial_success | +| 所有目标 Word 均失败 | failed | +| 无来源文件汇总批次 | waiting_user | + +--- + +## 十、接口详细设计 + +### 10.1 发起自动填表 + +```text +POST /api/review-agent/application-form-fill/start/ +``` + +请求: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| conversation_id | integer | 是 | 当前对话 | +| message_id | integer | 否 | 触发消息 | +| file_summary_batch_id | integer | 否 | 指定文件来源批次 | +| template_codes | array | 否 | 指定模板 | +| output_types | array | 否 | 输出类型,默认 word、excel、json | + +响应: + +```json +{ + "batch_id": 3001, + "workflow_type": "application_form_fill", + "status": "pending", + "selected_templates": ["registration_certificate", "essential_principles"] +} +``` + +### 10.2 查询状态 + +```text +GET /api/review-agent/application-form-fill/{batch_id}/ +``` + +响应: + +```json +{ + "batch": { + "id": 3001, + "batch_no": "AFF-20260607153000-a1b2c3", + "status": "success", + "product_name": "甲胎蛋白检测试剂盒", + "selected_templates": ["registration_certificate"] + }, + "nodes": [], + "conflicts": [], + "exports": [] +} +``` + +### 10.3 下载文件 + +继续复用既有导出下载接口: + +```text +GET /api/review-agent/file-summary/exports/{export_id}/download/ +``` + +下载权限通过 `workflow_type=application_form_fill` 和 `workflow_batch_id` 反查填表批次。 + +--- + +## 十一、前端详细设计 + +### 11.1 工作流卡片 + +新增卡片类型 `application_form_fill`。 + +| 节点 | 展示 | +| --- | --- | +| prepare | 准备资料 | +| template_select | 选择模板 | +| template_copy | 复制模板 | +| field_extract | 抽取字段 | +| conflict_merge | 冲突归并 | +| word_fill | 填写 Word | +| pdf_convert | 转换 PDF | +| trace_export | 追溯清单 | +| output_export | 输出下载 | +| notify | 飞书通知 | +| completed | 已完成 | + +PDF 本期显示为“已跳过/待增强”,不显示为失败。 + +### 11.2 AI 回复摘要 + +```markdown +已生成申报模板自动填表文件。 + +| 文件 | Word | PDF | +| --- | --- | --- | +| 注册证格式 | 下载 | 待增强 | +| 安全和性能基本原则清单 | 下载 | 待增强 | + +| 冲突字段 | 采用值 | 冲突来源 | 处理 | +| --- | --- | --- | --- | +| 储存条件 | 2-8℃保存 | 产品技术要求:-20℃保存 | 已按说明书填入,并在模板中高亮 | + +[下载字段来源追溯清单](download-url) +``` + +--- + +## 十二、异常与降级 + +| 场景 | 处理 | +| --- | --- | +| 无成功汇总批次 | 批次 waiting_user,对话提示上传资料 | +| 模板配置不存在 | 批次 failed | +| 指定模板不存在 | 忽略无效模板并提示;若无有效模板则 failed | +| `.doc` 模板无可用工作模板 | 该模板失败,其他模板继续 | +| 文本抽取失败 | 对应文件跳过,记录在追溯清单 | +| LLM 抽取失败 | 使用规则/正则结果继续 | +| 字段缺失 | Word 留空 | +| 字段冲突 | 说明书优先并高亮 | +| 追溯清单失败 | Word 成功时批次 partial_success | +| 飞书通知失败 | 批次 partial_success 或 success,取决于核心产物是否成功 | +| PDF 未实现 | 节点 skipped,写入待增强提示 | + +--- + +## 十三、测试设计 + +### 13.1 单元测试 + +| 用例 | 目标 | +| --- | --- | +| test_form_fill_trigger_keywords | 触发语句识别为自动填表 | +| test_template_config_loads | YAML 配置可加载并校验 | +| test_select_default_templates_initial_registration | 首次注册默认选择注册证和基本原则清单 | +| test_select_user_requested_mismatch | 用户指定不适用模板仍允许生成并提示 | +| test_field_merge_prefers_instructions | 说明书字段优先 | +| test_field_merge_marks_conflict | 冲突字段进入 conflict_summary | +| test_word_fill_table_row | 能按表格行名写入 Word | +| test_word_fill_conflict_highlight | 冲突字段黄底红字 | +| test_traceability_excel | 追溯清单包含字段、来源和冲突 | +| test_notify_records_failure | 飞书失败写通知记录但不阻断 | + +### 13.2 集成测试 + +| 场景 | 验证 | +| --- | --- | +| 最近汇总批次触发填表 | 无附件时复用最近 success `FileSummaryBatch` | +| 新附件触发填表 | 先自动汇总再启动填表 | +| 注册证模板填充 | 生成 Word 导出文件 | +| LLM 失败降级 | LLM 超时后规则抽取仍可生成 Word | +| 部分模板失败 | 至少一个 Word 成功时批次 partial_success | +| 权限隔离 | 不能查询或下载他人填表批次产物 | + +### 13.3 前端验证 + +| 场景 | 验证 | +| --- | --- | +| 自动填表卡片 | 节点状态随 SSE 更新 | +| 指定模板展示 | 卡片展示本次选择模板 | +| PDF 跳过显示 | PDF 节点显示待增强而非失败 | +| 下载链接 | Word 和追溯清单链接可点击下载 | +| 冲突摘要 | 冲突字段表格正常渲染 | + +--- + +## 十四、实施顺序建议 + +1. 修改功能设计中的模板配置路径为 `review_agent/application_form_fill/templates/application_form_templates_v1.yaml`。 +2. 新增数据库模型和 `ExportedSummaryFile.ExportType` 扩展。 +3. 新增 `application_form_fill` 模块目录和常量、schemas、storage。 +4. 新增模板配置 YAML,先录入注册证 `.docx` 的已识别字段。 +5. 实现模板选择、模板复制和 Word 表格行填充。 +6. 实现规则/正则字段抽取和 LLM 抽取降级。 +7. 实现字段合并、冲突高亮和追溯清单。 +8. 实现工作流执行器、节点事件和状态接口。 +9. 改造路由和前端工作流卡片。 +10. 接入飞书通知记录。 +11. 将字段级数据库表和 PDF 转换写入待办计划。 diff --git a/docs/4.详细设计/4.飞书通知与问答接入.md b/docs/4.详细设计/4.飞书通知与问答接入.md new file mode 100644 index 0000000..ec28e5a --- /dev/null +++ b/docs/4.详细设计/4.飞书通知与问答接入.md @@ -0,0 +1,604 @@ +# 飞书通知与问答接入详细设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/4.飞书通知与问答接入.md | +| 功能设计文档 | docs/2.功能设计/4.飞书通知与问答接入.md | +| 数据库设计文档 | docs/4.数据库设计/4.飞书通知与问答接入.md | +| 所属模块 | 审核智能体 review_agent | +| 设计日期 | 2026-06-07 | +| 设计版本 | V1.0 | + +--- + +## 一、实现目标 + +首期实现一个统一飞书通知能力,使自动汇总、法规核查、自动填表三个工作流在完成、部分成功或失败时,通过飞书官方智能体/应用机器人消息 API 向指定个人账号发送富文本私聊通知。通知失败不阻断主流程,发送结果落库并在批次详情页展示。 + +同时预留飞书私聊问答所需的用户映射、查询服务、权限过滤和问答日志模型,但不实现飞书事件订阅回调。 + +--- + +## 二、推荐文件结构 + +| 文件 | 类型 | 责任 | +| --- | --- | --- | +| `review_agent/models.py` | 修改 | 新增 `FeishuUserMapping`、`WorkflowNotificationRecord`、`FeishuQuestionLog` | +| `review_agent/admin.py` | 修改/新增 | 注册飞书用户映射和通知记录后台 | +| `review_agent/notifications/__init__.py` | 新增 | 通知模块包 | +| `review_agent/notifications/context.py` | 新增 | 定义统一通知上下文 dataclass | +| `review_agent/notifications/recipient.py` | 新增 | 解析首期指定个人接收人;后续扩展为按系统用户映射解析 | +| `review_agent/notifications/message_builder.py` | 新增 | 构造飞书富文本 payload 和摘要 | +| `review_agent/notifications/feishu_token.py` | 新增 | 使用 App ID/App Secret 获取并缓存 tenant_access_token | +| `review_agent/notifications/feishu_message_api.py` | 新增 | 调用飞书发送消息 API、处理响应解析 | +| `review_agent/notifications/records.py` | 新增 | 判重和通知记录落库 | +| `review_agent/notifications/dispatcher.py` | 新增 | 对外统一发送入口 | +| `review_agent/notifications/workflow_adapters.py` | 新增 | 三个工作流批次到通知上下文的适配 | +| `review_agent/feishu_questions/query.py` | 新增 | 后续问答预留:批次摘要查询 | +| `review_agent/feishu_questions/permissions.py` | 新增 | 后续问答预留:权限过滤 | +| `tests/test_feishu_notification.py` | 新增 | 飞书通知单元测试 | +| `tests/test_feishu_question_reserved.py` | 新增 | 问答预留服务测试 | + +--- + +## 三、数据结构设计 + +### 3.1 NotificationContext + +```python +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class NotificationContext: + workflow_type: str + workflow_batch_id: int + workflow_batch_no: str + workflow_status: str + title: str + trigger_user_id: int + trigger_username: str + result_url: str + summary_lines: list[str] = field(default_factory=list) + next_action: str = "" + metadata: dict[str, Any] = field(default_factory=dict) + + @property + def dedupe_key(self) -> str: + return f"{self.workflow_type}:{self.workflow_batch_id}:{self.workflow_status}" +``` + +### 3.2 ResolvedFeishuTarget + +```python +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ResolvedFeishuTarget: + mapping_id: int | None + display_name: str + identifier_type: str + identifier_value: str + masked_identifier: str + missing: bool = False +``` + +identifier_type 取值: + +| 值 | 说明 | +| --- | --- | +| open_id | 使用飞书 open_id | +| user_id | 使用飞书 user_id | +| mobile | 使用手机号,后续按发起人私聊时使用 | +| missing | 未配置映射 | + +--- + +## 四、模型详细设计 + +### 4.1 FeishuUserMapping + +字段见数据库设计。模型需提供方法: + +```python +def preferred_identifier(self) -> tuple[str, str]: + if self.feishu_open_id: + return "open_id", self.feishu_open_id + if self.feishu_user_id: + return "user_id", self.feishu_user_id + if self.feishu_mobile: + return "mobile", self.feishu_mobile + return "missing", "" +``` + +`clean()` 校验: + +```python +def clean(self): + if not (self.feishu_open_id or self.feishu_user_id or self.feishu_mobile): + raise ValidationError("feishu_open_id、feishu_user_id、feishu_mobile 至少填写一个") +``` + +### 4.2 WorkflowNotificationRecord + +字段见数据库设计。建议方法: + +```python +@classmethod +def already_sent(cls, dedupe_key: str) -> bool: + return cls.objects.filter(dedupe_key=dedupe_key, send_status=cls.SendStatus.SUCCESS).exists() +``` + +注意:若使用唯一约束限制 `dedupe_key`,重复触发时可以直接返回已有记录;若希望保留 skipped_duplicate 记录,则不能对 dedupe_key 做全局唯一,只能用查询判重。本项目需求是“只发一次”,更推荐保留唯一成功意图,重复触发返回已有记录或创建 skipped 记录需在实现计划中二选一。为了 SQLite 简化,首期建议不创建 skipped 记录,直接返回已有成功记录。 + +--- + +## 五、核心服务详细设计 + +### 5.1 workflow_adapters.py + +职责:把不同批次对象转换为 `NotificationContext`。 + +函数: + +```python +def build_file_summary_context(batch: FileSummaryBatch) -> NotificationContext: ... +def build_regulatory_review_context(batch: RegulatoryReviewBatch) -> NotificationContext: ... +def build_application_form_fill_context(batch: ApplicationFormFillBatch) -> NotificationContext: ... +``` + +自动汇总摘要: + +| 字段 | 计算方式 | +| --- | --- | +| 文件总数 | `batch.items.count()` | +| 成功解析数 | 解析状态为 success 的 item 数 | +| 异常数 | failed、skipped、unsupported 等状态数量 | +| 导出文件数 | `ExportedSummaryFile` 中 workflow_type=file_summary 或 batch 关联文件数 | + +法规核查摘要: + +| 字段 | 计算方式 | +| --- | --- | +| 风险总数 | `batch.issues.count()` | +| 阻断项 | severity=blocking | +| 高风险 | severity=high | +| 中风险 | severity=medium | + +自动填表摘要: + +| 字段 | 计算方式 | +| --- | --- | +| 模板数 | `len(batch.selected_templates)` | +| 导出文件数 | 对应 `ExportedSummaryFile` 数量 | +| 冲突字段数 | `len(batch.conflict_summary or [])` | +| 失败原因 | `batch.error_message` 或节点错误摘要 | + +### 5.2 recipient.py + +职责:首期根据环境变量解析指定个人接收人;后续可扩展为根据系统用户解析飞书目标。 + +伪代码: + +```python +def resolve_feishu_target(user: User) -> ResolvedFeishuTarget: + if settings.FEISHU_DEFAULT_USER_OPEN_ID: + return ResolvedFeishuTarget( + mapping_id=None, + display_name=getattr(settings, "FEISHU_DEFAULT_TARGET_NAME", "指定个人账号"), + identifier_type="open_id", + identifier_value=settings.FEISHU_DEFAULT_USER_OPEN_ID, + masked_identifier=mask_identifier(settings.FEISHU_DEFAULT_USER_OPEN_ID), + missing=False, + ) + if settings.FEISHU_DEFAULT_USER_ID: + return ResolvedFeishuTarget( + mapping_id=None, + display_name=getattr(settings, "FEISHU_DEFAULT_TARGET_NAME", "指定个人账号"), + identifier_type="user_id", + identifier_value=settings.FEISHU_DEFAULT_USER_ID, + masked_identifier=mask_identifier(settings.FEISHU_DEFAULT_USER_ID), + missing=False, + ) + return ResolvedFeishuTarget( + mapping_id=None, + display_name=user.get_username(), + identifier_type="missing", + identifier_value="", + masked_identifier="", + missing=True, + ) + + +def resolve_feishu_target_by_user_mapping(user: User) -> ResolvedFeishuTarget: + mapping = ( + FeishuUserMapping.objects + .filter(system_user=user, is_active=True) + .first() + ) + if mapping is None: + return ResolvedFeishuTarget( + mapping_id=None, + display_name=user.get_username(), + identifier_type="missing", + identifier_value="", + masked_identifier="", + missing=True, + ) + identifier_type, identifier_value = mapping.preferred_identifier() + return ResolvedFeishuTarget( + mapping_id=mapping.pk, + display_name=mapping.feishu_display_name or user.get_username(), + identifier_type=identifier_type, + identifier_value=identifier_value, + masked_identifier=mask_identifier(identifier_value), + missing=identifier_type == "missing", + ) +``` + +脱敏规则: + +| 类型 | 规则 | +| --- | --- | +| mobile | 保留前三位和后四位,如 `138****1234` | +| open_id/user_id | 保留前 6 位和后 4 位 | +| missing | 空字符串 | + +首期调度器使用 `resolve_feishu_target()`。`resolve_feishu_target_by_user_mapping()` 作为后续“按发起人私聊”能力预留。 + +### 5.3 message_builder.py + +职责:构造富文本 payload 和入库摘要。 + +函数: + +```python +def build_feishu_post_message( + context: NotificationContext, + target: ResolvedFeishuTarget, +) -> dict: ... + +def build_message_summary( + context: NotificationContext, + target: ResolvedFeishuTarget, +) -> str: ... +``` + +富文本规则: + +| 场景 | 规则 | +| --- | --- | +| 有映射 | 加入 `at` 标签 | +| 无映射 | 不加入 `at` 标签,增加映射缺失提示 | +| 失败状态 | 标题和下一步动作突出失败原因摘要 | +| 摘要过长 | 每条摘要最多 120 字,总摘要最多 800 字 | +| 链接 | 使用本地地址拼接,后续再切换域名配置 | + +### 5.4 feishu_token.py + +职责:使用 App ID/App Secret 获取并缓存 `tenant_access_token`。 + +函数: + +```python +def get_tenant_access_token() -> FeishuTokenResult: ... +def refresh_tenant_access_token() -> FeishuTokenResult: ... +``` + +结果结构: + +```python +@dataclass(frozen=True) +class FeishuTokenResult: + ok: bool + tenant_access_token: str + expire_seconds: int + code: str + message: str +``` + +处理规则: + +| 场景 | 处理 | +| --- | --- | +| App ID/App Secret 缺失 | 返回 failed,错误码 config_missing | +| 缓存 token 未过期 | 直接返回缓存 token | +| token 过期或不存在 | 调用飞书 token API 重新获取 | +| token API 返回失败 | 返回 failed,记录 code/message | +| HTTP 超时 | 返回 failed,错误码 timeout | + +### 5.5 feishu_message_api.py + +职责:调用飞书发送消息 API。 + +函数: + +```python +def send_personal_message( + *, + tenant_access_token: str, + receive_id_type: str, + receive_id: str, + payload: dict, +) -> FeishuMessageApiResult: ... +``` + +结果结构: + +```python +@dataclass(frozen=True) +class FeishuMessageApiResult: + ok: bool + status_code: int | None + code: str + message: str + duration_ms: int + message_id: str = "" +``` + +异常处理: + +| 异常 | 处理 | +| --- | --- | +| 指定接收人缺失 | 返回 failed,错误码 recipient_missing | +| tenant_access_token 缺失 | 返回 failed,错误码 token_missing | +| HTTP 超时 | 返回 failed,错误码 timeout | +| 非 2xx | 返回 failed,记录 status_code | +| 飞书返回 code 非 0 | 返回 failed,记录 code/message | +| token 失效 | 刷新 token 后允许同步重试一次消息 API | + +### 5.6 records.py + +职责:判重和落库。 + +流程: + +```text +输入 NotificationContext +-> 查询 dedupe_key 是否已有 success +-> 若有,返回已有记录,不发送 +-> 若未启用真实飞书,创建 disabled/mock 记录 +-> 若发送成功,创建 success 记录 +-> 若发送失败,创建 failed 记录 +``` + +字段写入规则: + +| 字段 | 来源 | +| --- | --- | +| workflow_type | context.workflow_type | +| workflow_batch_id | context.workflow_batch_id | +| workflow_batch_no | context.workflow_batch_no | +| workflow_status | context.workflow_status | +| dedupe_key | context.dedupe_key | +| trigger_user_id | context.trigger_user_id | +| feishu_mapping_id | target.mapping_id | +| at_identifier_type | target.identifier_type | +| at_identifier_masked | target.masked_identifier | +| message_summary | `build_message_summary()` | + +### 5.7 dispatcher.py + +对外入口: + +```python +def dispatch_workflow_notification(context: NotificationContext) -> WorkflowNotificationRecord: + if WorkflowNotificationRecord.already_sent(context.dedupe_key): + return WorkflowNotificationRecord.objects.get( + dedupe_key=context.dedupe_key, + send_status=WorkflowNotificationRecord.SendStatus.SUCCESS, + ) + + user = User.objects.get(pk=context.trigger_user_id) + target = resolve_feishu_target(user) + message = build_feishu_post_message(context, target) + summary = build_message_summary(context, target) + + if not settings.FEISHU_NOTIFY_ENABLED: + return create_disabled_record(context, target, summary) + + token_result = get_tenant_access_token() + if not token_result.ok: + return create_failed_record(context, target, summary, token_result) + + result = send_personal_message( + tenant_access_token=token_result.tenant_access_token, + receive_id_type=target.identifier_type, + receive_id=target.identifier_value, + payload=message, + ) + if result.ok: + return create_success_record(context, target, summary, result) + return create_failed_record(context, target, summary, result) +``` + +--- + +## 六、工作流接入点 + +| 工作流 | 推荐接入位置 | +| --- | --- | +| 自动汇总 | 文件汇总批次状态写为 success/partial_success/failed 后 | +| 法规核查 | 报告导出和风险项保存后;替换或并行现有 `create_mock_notifications` | +| 自动填表 | `notify` 节点中替换或扩展现有 `notify_completion` | + +接入原则: + +| 原则 | 说明 | +| --- | --- | +| 通知异常捕获 | 工作流调用通知服务时捕获异常并记录 non_blocking_errors | +| 不回滚业务结果 | 通知失败不修改业务批次成功状态 | +| 单点适配 | 工作流只负责生成或传入批次,摘要由 adapter 负责 | + +--- + +## 七、批次详情展示设计 + +### 7.1 后端上下文 + +为批次详情页提供: + +```python +def get_notification_records(workflow_type: str, batch_id: int) -> QuerySet: + return WorkflowNotificationRecord.objects.filter( + workflow_type=workflow_type, + workflow_batch_id=batch_id, + ).order_by("-created_at") +``` + +### 7.2 页面展示规则 + +| 状态 | 展示 | +| --- | --- | +| success | “飞书通知已发送”,展示 sent_at | +| failed | “飞书通知失败”,展示 error_message | +| disabled | “飞书通知未启用” | +| 无记录 | “暂无通知记录” | + +三个工作流结果页可复用同一 partial 模板或上下文字段。 + +--- + +## 八、问答预留详细设计 + +### 8.1 批次摘要查询服务 + +预留函数: + +```python +def query_batch_summary( + user: User, + *, + workflow_type: str | None = None, + batch_no: str | None = None, + latest: bool = False, +) -> dict: + ... +``` + +权限规则: + +| 用户 | 可查范围 | +| --- | --- | +| 管理员 | 全部批次 | +| 普通用户 | `batch.user == user` 的批次 | +| 未绑定用户 | 不可查 | + +查询对象: + +| 类型 | 说明 | +| --- | --- | +| 明确批次号 | 精确匹配 batch_no | +| 最近/最新 | 在有权限范围内按 created_at/finished_at 倒序取第一条 | +| 工作流类型 | file_summary、regulatory_review、application_form_fill | + +### 8.2 问答日志服务 + +预留函数: + +```python +def record_feishu_question_log( + *, + user: User | None, + mapping: FeishuUserMapping | None, + source_type: str, + question_text: str, + intent: str, + query_object: dict, + answer_summary: str, + permission_result: str, + status: str, + error_message: str = "", +) -> FeishuQuestionLog: + ... +``` + +首期不需要接飞书事件,但测试可直接调用该服务,确认日志字段与权限规则可用。 + +--- + +## 九、测试设计 + +### 9.1 单元测试 + +| 测试文件 | 用例 | +| --- | --- | +| `tests/test_feishu_notification.py` | tenant_access_token 获取和缓存 | +| `tests/test_feishu_notification.py` | 指定个人接收人优先级 open_id > user_id | +| `tests/test_feishu_notification.py` | 指定接收人缺失时写 failed 记录 | +| `tests/test_feishu_notification.py` | 真实通知关闭时写 disabled/mock 记录 | +| `tests/test_feishu_notification.py` | 消息 API 成功写 success 记录 | +| `tests/test_feishu_notification.py` | token 获取失败写 failed 记录 | +| `tests/test_feishu_notification.py` | 消息 API 超时写 failed 记录 | +| `tests/test_feishu_notification.py` | 同一 dedupe_key 不重复发送 | +| `tests/test_feishu_question_reserved.py` | 管理员可查询全部批次摘要 | +| `tests/test_feishu_question_reserved.py` | 普通用户只能查询自己的批次 | +| `tests/test_feishu_question_reserved.py` | 问答日志不保存完整回答正文 | + +### 9.2 集成测试 + +| 场景 | 验证 | +| --- | --- | +| 自动汇总完成 | 生成通知上下文并写记录 | +| 法规核查完成 | 风险摘要正确 | +| 自动填表完成 | 导出和冲突摘要正确 | +| 批次详情页 | 展示通知状态和失败原因 | + +### 9.3 外部飞书测试 + +真实飞书 API 测试不进入默认 CI。建议提供手动命令或 Django management command: + +```text +python manage.py send_test_feishu_notification --username owner +``` + +该命令只在本地配置 `FEISHU_NOTIFY_ENABLED=true`、`FEISHU_APP_ID`、`FEISHU_APP_SECRET`、`FEISHU_DEFAULT_USER_OPEN_ID` 或 `FEISHU_DEFAULT_USER_ID` 后使用。 + +--- + +## 十、异常处理 + +| 异常 | 处理 | +| --- | --- | +| 指定接收人缺失 | 不发送真实消息,记录 recipient_missing | +| App ID/App Secret 未配置 | 写 failed 或 disabled 记录,不发送 | +| tenant_access_token 获取失败 | 写 failed,记录 token API 错误 | +| 指定接收人 open_id/user_id 未配置 | 写 failed,错误码 recipient_missing | +| HTTP 超时 | 写 failed,错误码 timeout | +| 飞书返回错误 | 写 failed,记录 code/message | +| 通知记录唯一冲突 | 查询已有记录并返回,不重复发送 | +| 批次链接生成失败 | 发送无链接摘要,记录 warning 到 message_summary | + +--- + +## 十一、日志与安全 + +| 项 | 要求 | +| --- | --- | +| 日志脱敏 | 不打印 App Secret、tenant_access_token、完整手机号 | +| 入库脱敏 | 通知记录只保存脱敏接收人标识 | +| payload | 不保存完整富文本 payload | +| 错误信息 | 保存飞书错误摘要,避免保存敏感请求头 | +| 问答日志 | 保存问题、意图、对象和回答摘要,不保存完整回答 | + +--- + +## 十二、实施顺序建议 + +| 顺序 | 内容 | +| --- | --- | +| 1 | 新增模型、迁移和 Admin | +| 2 | 实现用户映射解析和脱敏 | +| 3 | 实现飞书富文本构造 | +| 4 | 实现 tenant_access_token 获取与缓存 | +| 5 | 实现飞书消息 API 发送客户端 | +| 6 | 实现通知记录判重和落库 | +| 7 | 实现三个工作流 adapter | +| 8 | 接入三个工作流完成节点 | +| 9 | 批次详情页展示通知状态 | +| 10 | 实现问答预留查询服务和日志服务 | +| 11 | 补齐单元测试和集成测试 | diff --git a/docs/4.详细设计/5.第1章监管信息材料包生成.md b/docs/4.详细设计/5.第1章监管信息材料包生成.md new file mode 100644 index 0000000..a7998dd --- /dev/null +++ b/docs/4.详细设计/5.第1章监管信息材料包生成.md @@ -0,0 +1,963 @@ +# 第1章监管信息材料包生成详细设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/5.第1章监管信息材料包生成.md | +| 功能设计文档 | docs/2.功能设计/5.第1章监管信息材料包生成.md | +| 数据库设计文档 | docs/3.数据库设计/5.第1章监管信息材料包生成.md | +| 参考详细设计 | docs/4.详细设计/3.产品关键信息提取与申报文件自动填表.md | +| 功能名称 | 第1章监管信息材料包生成 | +| 工作流编码 | regulatory_info_package | +| 所属模块 | 审核智能体 review_agent | +| 设计日期 | 2026-06-10 | +| 设计版本 | V1.0 | + +--- + +## 一、详细设计目标 + +本详细设计用于指导 `regulatory_info_package` 独立工作流开发落地。系统根据用户上传或指定的产品说明书,抽取产品关键信息,基于 `docs/0.原始材料/第1章 监管信息` 下的样例模板生成第1章监管信息材料包,并以 `第1章 监管信息(预生成版).zip` 作为对话摘要首位下载入口。 + +核心约束: + +| 约束 | 说明 | +| --- | --- | +| 独立工作流 | 使用 `workflow_type=regulatory_info_package`,拥有独立批次、产物、通知和卡片 | +| 独立模块 | 新增 `review_agent/regulatory_info_package/`,与 `application_form_fill` 平级 | +| 模型集中 | Django 模型仍集中放在 `review_agent/models.py` | +| 节点幂等 | `WorkflowNodeRun` 必须按 `workflow_type + workflow_batch_id + node_code` 幂等创建或加唯一约束 | +| 输入优先级 | 用户消息指定文件名优先;其次 active 附件;再兼容最近成功文件汇总 | +| 模板固定 | 固定处理第1章监管信息 7 个模板 | +| 模板字段化 | 生成逻辑优先写 Word 内容控件 Tag 或稳定占位符,不以手工调整表格格式为前提 | +| 规则优先可演示 | 规则抽取可独立跑通;LLM 失败最多重试 3 次,失败后继续 | +| 文档并发生成 | 工作流整体串行,`generate_docs` 节点内部每个文档可独立线程并发处理 | +| `.doc` 兜底 | 能力驱动:有 Word COM/UNO 时优先原生 `.doc`;无原生能力或原生失败时允许生成 `.docx` 兜底文件 | +| zip 只含成功文件 | zip 只打包成功或兜底成功的文件;失败文件不进入 zip | +| 高亮规则 | 缺失和 LLM-only 黄底;冲突黄底红字 | +| 追溯输出 | 用户下载 Excel;JSON 仅保存到后台 logs 目录 | +| 前端最小接入 | 不做多说明书选择 UI;不确定时通过对话反问 | + +--- + +## 二、代码结构设计 + +### 2.1 目录结构 + +```text +review_agent/ + models.py + services.py + skill_router.py + regulatory_info_package/ + __init__.py + constants.py + schemas.py + storage.py + events.py + workflow.py + views.py + services/ + __init__.py + input_select.py + template_config.py + template_repository.py + instruction_extract.py + field_extract.py + field_merge.py + standard_candidates.py + document_writer.py + docx_document.py + legacy_doc_document.py + package_generate.py + traceability_export.py + zip_export.py + summary.py + notifier.py + templates/ + regulatory_info_package_templates_v1.yaml + prompts/ + field_extract.md +``` + +### 2.2 文件职责 + +| 文件 | 职责 | +| --- | --- | +| constants.py | 工作流编码、节点定义、触发关键词、模板编码、状态常量 | +| schemas.py | dataclass 数据结构,如 `TemplateSpec`、`InstructionExtractResult`、`MergedField`、`GeneratedFileResult` | +| storage.py | 批次目录、子目录、hash、产物创建、路径安全校验 | +| events.py | 记录与序列化 `WorkflowEvent` | +| workflow.py | `RegulatoryInfoPackageWorkflowExecutor`、批次创建、工作流启动 | +| views.py | health、start、status、select-input 接口 | +| input_select.py | 根据用户消息、active 附件、文件汇总选择说明书 | +| template_config.py | YAML 加载、校验、hash | +| template_repository.py | 定位样例模板、复制到批次目录、审计字段 Tag/占位符 | +| instruction_extract.py | 说明书段落、章节、表格和组成成分表解析 | +| field_extract.py | 规则抽取与 LLM 抽取并行执行,LLM 最多 3 次重试 | +| field_merge.py | 合并字段,输出缺失、LLM-only、冲突和高亮决策 | +| standard_candidates.py | 从说明书抽标准号,调用现有知识库搜索候选 | +| document_writer.py | 文档适配器接口与通用高亮策略 | +| docx_document.py | `DocxDocumentAdapter`,处理 `.docx` | +| legacy_doc_document.py | `LegacyDocDocumentAdapter`,处理 `.doc` 原生写入与 `.docx` 兜底 | +| package_generate.py | 7 个文档生成策略,多线程生成文件 | +| traceability_export.py | 生成 `exports/traceability.xlsx` 和 `logs/traceability.json` | +| zip_export.py | 生成主下载 zip,只包含成功文件 | +| summary.py | 构造助手回显,zip 链接排首位 | +| notifier.py | 写专项通知记录,并调用统一通知服务 | + +--- + +## 三、数据模型详细设计 + +模型放在 `review_agent/models.py`。 + +### 3.1 RegulatoryInfoPackageBatch + +```python +class RegulatoryInfoPackageBatch(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "待执行" + RUNNING = "running", "执行中" + WAITING_USER = "waiting_user", "等待用户" + SUCCESS = "success", "成功" + PARTIAL_SUCCESS = "partial_success", "部分成功" + FAILED = "failed", "失败" + CANCELLED = "cancelled", "已取消" +``` + +关键字段: + +| 字段 | 说明 | +| --- | --- | +| conversation | 所属对话 | +| user | 发起用户 | +| trigger_message | 触发消息 | +| source_attachment | 直接选中的说明书附件,可空 | +| source_summary_batch | 兼容文件汇总批次,可空 | +| source_summary_item_id | 文件汇总条目 ID,可空 | +| batch_no | `RIP-YYYYMMDDHHMMSS-abcdef` | +| source_file_name | 说明书原文件名 | +| source_storage_path | 说明书存储路径 | +| product_name | 抽取产品名称 | +| output_zip_name | `第1章 监管信息(预生成版).zip` | +| generated_files | 7 个文件状态 | +| missing_fields | 缺失字段 | +| llm_only_fields | LLM-only 字段 | +| conflict_fields | 冲突字段 | +| risk_notes | 风险和降级提示 | +| adapter_summary | doc/docx 适配器实际执行摘要 | +| template_config_version/hash | 模板配置版本和 hash | +| work_dir | 批次工作目录 | +| is_deleted | 软删除 | + +### 3.2 RegulatoryInfoPackageArtifact + +```python +class RegulatoryInfoPackageArtifact(models.Model): + class ArtifactType(models.TextChoices): + TEMPLATE_COPY = "template_copy", "模板副本" + INSTRUCTION_EXTRACT = "instruction_extract", "说明书抽取结果" + FIELD_EXTRACT_RESULT = "field_extract_result", "字段抽取结果" + MERGED_FIELDS = "merged_fields", "合并字段" + GENERATED_DOCUMENT = "generated_document", "生成文件" + TRACEABILITY = "traceability", "追溯清单" + ZIP_PACKAGE = "zip_package", "ZIP包" + NOTIFICATION_RECORD = "notification_record", "通知记录" +``` + +`file_format` 包含:`json`、`excel`、`docx`、`doc`、`zip`、`markdown`。 + +### 3.3 RegulatoryInfoPackageNotificationRecord + +字段对齐自动填表通知记录:`batch`、`recipient`、`channel`、`export_ids`、`message_summary`、`send_status`、`retry_count`、`external_message_id`、`error_message`、`sent_at`、`is_deleted`。 + +### 3.4 ExportedSummaryFile 扩展 + +`ExportedSummaryFile.ExportType` 增加: + +```python +ZIP = "zip", "ZIP" +``` + +下载 MIME 按扩展名兜底: + +| 条件 | MIME | +| --- | --- | +| zip | application/zip | +| .doc | application/msword | +| .docx | application/vnd.openxmlformats-officedocument.wordprocessingml.document | + +--- + +## 四、常量设计 + +### 4.1 工作流常量 + +```python +WORKFLOW_TYPE = "regulatory_info_package" +DEFAULT_ZIP_NAME = "第1章 监管信息(预生成版).zip" + +REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS = [ + ("prepare", "准备资料", "regulatory_info_package"), + ("template_copy", "复制模板", "regulatory_info_package"), + ("text_extract", "抽取说明书", "regulatory_info_package"), + ("field_extract", "抽取字段", "regulatory_info_package"), + ("field_merge", "合并字段", "regulatory_info_package"), + ("generate_docs", "生成材料", "regulatory_info_package"), + ("highlight_review_items", "标记待确认", "regulatory_info_package"), + ("trace_export", "追溯清单", "regulatory_info_package"), + ("zip_export", "打包下载", "regulatory_info_package"), + ("notify", "通知", "regulatory_info_package"), + ("completed", "完成", "completed"), +] +``` + +### 4.2 触发关键词 + +```python +REGULATORY_INFO_PACKAGE_TRIGGER_KEYWORDS = [ + "根据说明书生成第1章监管信息", + "生成监管信息材料包", + "从说明书生成第1章材料", + "第1章监管信息", + "监管信息材料包", +] +``` + +### 4.3 文件状态 + +```python +GENERATED_FILE_SUCCESS = "success" +GENERATED_FILE_FALLBACK_SUCCESS = "fallback_success" +GENERATED_FILE_FAILED = "failed" +GENERATED_FILE_SKIPPED = "skipped" +``` + +--- + +## 五、核心数据结构 + +### 5.1 TemplateSpec + +```python +@dataclass(frozen=True) +class TemplateSpec: + code: str + output_name: str + source_file: str + file_format: str + strategy: str + include_in_zip: bool + prefer_legacy_doc_native: bool = False + allow_docx_fallback: bool = True + fields: list[dict[str, Any]] = field(default_factory=list) +``` + +### 5.2 InstructionExtractResult + +```python +@dataclass +class InstructionExtractResult: + source_file_name: str + paragraphs: list[str] + sections: dict[str, str] + tables: list[list[list[str]]] + component_tables: list["ComponentTable"] + front_text: str +``` + +### 5.3 ProductListRow + +```python +@dataclass +class ProductListRow: + package_specification: str + item_no: str + composition: str + component_name: str + main_component: str + quantity: str + source_table_title: str + needs_review_fields: list[str] = field(default_factory=list) +``` + +其中 `item_no` 对应货号,本期固定 `/` 并黄底。 + +### 5.4 MergedField + +```python +@dataclass +class MergedField: + key: str + label: str + value: str + source: str + evidence: str + confidence: float + highlight_reason: str = "none" + needs_review: bool = False + rule_value: str = "" + llm_value: str = "" +``` + +### 5.5 GeneratedFileResult + +```python +@dataclass +class GeneratedFileResult: + template_code: str + file_name: str + requested_format: str + actual_format: str + status: str + path: str = "" + artifact_id: int | None = None + export_id: int | None = None + highlight_count: int = 0 + missing_count: int = 0 + llm_only_count: int = 0 + error_message: str = "" +``` + +--- + +## 六、存储目录设计 + +```text +media/regulatory_info_package/{user_id}/{conversation_id}/{batch_no}/ + templates/ + logs/ + instruction_extract.json + field_extract_result.json + merged_fields.json + doc_adapter_result.json + traceability.json + generated/ + CH1.2 监管信息目录.docx + CH1.4 申请表.docx + CH1.5 产品列表.docx + CH1.9 产品申报前沟通的说明.docx + CH1.11.1 符合标准的清单.docx + CH1.11.5 真实性声明.docx + CH1.11.6 符合性声明.docx + exports/ + traceability.xlsx + 第1章 监管信息(预生成版).zip +``` + +说明: + +| 目录 | 说明 | +| --- | --- | +| templates | 模板副本 | +| logs | 后台 JSON 产物,不作为用户主下载 | +| generated | 生成成功或兜底成功的单文件 | +| exports | 用户可下载的追溯 Excel 和 zip | + +--- + +## 七、输入选择详细设计 + +### 7.1 选择优先级 + +`input_select.py` 的选择顺序: + +1. 用户消息显式指定文件名时,按 active 附件名模糊匹配。 +2. 当前对话 active 附件中文件名包含“说明书”的 `.docx`。 +3. 当前对话 active 附件中唯一 `.docx`。 +4. 最近成功 `FileSummaryBatch.items` 中包含“说明书”的 `.docx`。 +5. 多候选或无候选时返回 `InputSelectionResult(status="waiting_user")`。 + +### 7.2 多候选处理 + +本期不新增在线选择弹窗。多候选时: + +| 场景 | 处理 | +| --- | --- | +| 用户消息可模糊匹配唯一附件 | 直接选择 | +| 多个候选且无法确定 | 对话反问用户确认哪个说明书 | +| 无说明书 | 提示上传产品说明书 | + +反问示例: + +```text +我找到多个说明书候选,请回复要使用的文件名:A.docx、B.docx。 +``` + +--- + +## 八、模板配置详细设计 + +配置路径: + +```text +review_agent/regulatory_info_package/templates/regulatory_info_package_templates_v1.yaml +``` + +必须包含 7 个模板: + +| code | source_file | strategy | +| --- | --- | --- | +| ch1_2_directory | CH1.2 监管信息目录.docx | directory | +| ch1_4_application_form | CH1.4 申请表.docx | application_form | +| ch1_5_product_list | CH1.5 产品列表.docx | product_list | +| ch1_9_pre_submission | CH1.9 产品申报前沟通的说明.doc | pre_submission | +| ch1_11_1_standard_list | CH1.11.1 符合标准的清单.docx | standard_list | +| ch1_11_5_authenticity | CH1.11.5 真实性声明.docx | authenticity_statement | +| ch1_11_6_compliance | CH1.11.6 符合性声明.docx | compliance_statement | + +校验规则: + +| 校验 | 说明 | +| --- | --- | +| version 必填 | 写入批次 | +| source_dir 存在 | 指向样例目录 | +| code 唯一 | 防止覆盖产物 | +| source_file 存在 | 缺失则配置错误 | +| strategy 合法 | 必须命中生成策略 | +| doc 模板标记 | `.doc` 模板需声明 `prefer_legacy_doc_native`,并配置允许 `.docx` 兜底 | + +### 8.1 模板字段化约定 + +为避免生成时破坏 Word 表格、复选框、字号、缩进和合并单元格,本工作流优先使用字段化模板: + +| 方式 | 使用场景 | 说明 | +| --- | --- | --- | +| Word 内容控件 Tag | 正式模板优先 | 在 Word 中为产品名、申请人、复选框、日期、说明文字等填写区设置稳定 Tag,代码按 Tag 写入 | +| 稳定占位符 | 过渡方案 | 使用 `{{ product_name }}` 等不会影响版式的占位符,代码替换占位符所在 run | +| 行标签定位 | 兜底方案 | 仅用于未字段化的旧模板,必须保留原单元格、段落和 run 格式 | + +模板配置中的字段目标优先级: + +```yaml +targets: + - type: content_control_tag + tag: product_name + - type: placeholder + marker: "{{ product_name }}" + - type: table_row_label + label: 产品名称 +``` + +模板加载时必须执行字段审计:关键字段缺少 Tag/占位符时给出清晰错误或降级说明;不得静默使用会破坏格式的整格重建策略。 + +--- + +## 九、字段抽取详细设计 + +### 9.1 规则抽取 + +规则抽取必须独立可用,覆盖: + +| 字段 | 规则 | +| --- | --- | +| product_name | `【产品名称】` 下一段 | +| package_specification | `【包装规格】` 至下一章节 | +| intended_use | `【预期用途】` 至下一章节 | +| detection_principle | `【检测原理】` 至下一章节 | +| main_components | `【主要组成成分】` 下方表格摘要 | +| storage_condition_and_validity | `【储存条件及有效期】` 至下一章节 | +| sample_type | 样本要求章节中的“适用样本类型” | +| detection_targets | 预期用途/检测原理中的基因、病原体、靶标 | +| applicable_instruments | `【适用仪器】` 至下一章节 | +| test_method | `【检验方法】` 摘要 | +| standards | 正则抽取标准号 | + +### 9.2 LLM 抽取与重试 + +`field_extract.py` 并行执行规则抽取和 LLM 抽取: + +```text +ThreadPoolExecutor(max_workers=2) + -> rule_extract() + -> llm_extract_with_retry(max_attempts=3) +``` + +LLM 重试策略: + +| 次数 | 间隔 | +| --- | --- | +| 第 1 次 | 立即 | +| 第 2 次 | 等待 1 秒 | +| 第 3 次 | 等待 2 秒 | + +三次失败后: + +| 产物 | 处理 | +| --- | --- | +| risk_notes | 增加 `llm_extract_failed` | +| logs/field_extract_result.json | 记录每次错误摘要 | +| 工作流 | 继续使用规则结果 | + +LLM 不允许填企业信息、分类编码、管理类别、临床评价路径等说明书无法证明的内容。 + +### 9.3 字段合并 + +| 场景 | 写入值 | 高亮 | needs_review | +| --- | --- | --- | --- | +| rule 与 LLM 一致 | rule/LLM 值 | 否 | 否 | +| rule 与 LLM 冲突 | 规则优先或配置优先 | 黄底红字 | 是 | +| rule 缺失、LLM 命中 | LLM 值 | 黄底 | 是 | +| 全部缺失 | `/` | 黄底 | 是 | + +--- + +## 十、文档适配器详细设计 + +### 10.1 统一接口 + +```python +class DocumentAdapter(Protocol): + def replace_text(self, old: str, new: str, *, highlight: bool = False, conflict: bool = False) -> int: ... + def fill_table_cell(self, row_label: str, value: str, *, highlight: bool = False, conflict: bool = False) -> bool: ... + def replace_table(self, marker: str, rows: list[ProductListRow], *, highlight_columns: list[str] | None = None) -> bool: ... + def save(self, path: Path) -> Path: ... +``` + +高亮规则: + +| 类型 | 视觉 | +| --- | --- | +| missing | 黄色底色 | +| llm_only | 黄色底色 | +| conflict | 黄色底色 + 红色字体 | + +### 10.2 DocxDocumentAdapter + +实现能力: + +| 方法 | 说明 | +| --- | --- | +| replace_text | 支持段落与表格中的文本替换,需处理 run 拆分 | +| fill_content_control | 按内容控件 Tag 填写文本、日期或复选框 | +| replace_placeholder | 按稳定占位符替换文本,保留占位符所在 run/段落格式 | +| fill_table_cell | 按行标签定位目标单元格,仅作为未字段化模板的兜底 | +| replace_table | 重建 CH1.5 产品列表表格 | +| apply_highlight | 使用 `w:shd` 设置黄色底色 | +| apply_conflict_style | 黄色底色 + 红字 | + +### 10.3 LegacyDocDocumentAdapter + +接口: + +```python +class AdapterCapability: + adapter_name: str + supports_native_doc_write: bool + supports_docx_fallback: bool + status: str + error_message: str = "" + +class LegacyDocDocumentAdapter: + @staticmethod + def detect_available_adapter() -> AdapterCapability: ... +``` + +执行顺序: + +1. 执行能力探测:Word COM、LibreOffice UNO 或其他可写 `.doc` 能力。 +2. 有原生能力时优先尝试原生打开 `.doc` 并保存 `.doc`。 +3. 无原生能力或原生失败时,尝试生成同语义 `.docx` 兜底文件,再交给 `DocxDocumentAdapter`。 +4. 兜底成功时,输出 `CH1.9 产品申报前沟通的说明.docx`,状态为 `fallback_success`。 +5. 原生和兜底均失败时,该文件状态为 `failed`,不进入 zip。 + +兜底成功 `adapter_summary.doc`: + +```json +{ + "requested_format": "doc", + "actual_format": "docx", + "adapter": "ConversionFallbackAdapter", + "status": "fallback_success" +} +``` + +--- + +## 十一、材料生成详细设计 + +### 11.1 generate_docs 节点并发 + +工作流节点仍串行执行,但 `generate_docs` 内部并发生成单文件: + +```python +with ThreadPoolExecutor(max_workers=min(7, len(specs))) as executor: + futures = [executor.submit(generate_one_document, spec, context) for spec in specs] +``` + +并发注意事项: + +| 注意事项 | 说明 | +| --- | --- | +| 每个文档使用独立模板副本 | 避免并发写同一文件 | +| 共享字段只读 | `merged_fields`、`product_list_rows` 不在子线程修改 | +| 数据库写入集中处理 | 子线程返回 `GeneratedFileResult`,主线程统一写 artifact/export | +| 异常隔离 | 单文件失败不影响其他文件 | + +### 11.2 7 个生成策略 + +| 模板 | 输出规则 | +| --- | --- | +| CH1.2 | 替换产品名;页码沿用样例 | +| CH1.4 | 填产品名、包装规格、预期用途、组成、储存有效期、方法原理;企业/分类等缺失项 `/` 黄底 | +| CH1.5 | 按样例表头重建,货号 `/` 黄底 | +| CH1.9 | 优先 `.doc` 原生写入;失败则 `.docx` 兜底;兜底失败则不输出 | +| CH1.11.1 | 说明书标准号直接写;知识库候选只作为待确认高亮/追溯 | +| CH1.11.5 | 保留正文,替换产品名,公司名 `/` 黄底,日期当天 | +| CH1.11.6 | 保留正文,替换产品名,公司名 `/` 黄底,日期当天 | + +### 11.3 产品名缺失 + +规则和 LLM 都抽不到产品名称时: + +| 项 | 处理 | +| --- | --- | +| 文件内容 | 产品名位置写 `/` 并黄底 | +| 批次状态 | 至少 `partial_success` | +| zip | 仍生成,包含成功文件 | +| 摘要 | 明确提示产品名称待确认 | + +--- + +## 十二、追溯与 zip 设计 + +### 12.1 追溯 Excel + +用户可下载: + +```text +exports/traceability.xlsx +``` + +创建导出记录: + +```text +export_category = traceability +export_type = excel +``` + +字段: + +| 字段 | 说明 | +| --- | --- | +| target_file | 目标文件 | +| target_field | 目标字段 | +| final_value | 写入值 | +| extraction_source | rule、llm、missing、knowledge_candidate | +| evidence | 来源片段 | +| highlight_reason | missing、llm_only、conflict、rag_candidate | +| needs_review | 是否需复核 | + +### 12.2 后台 JSON + +JSON 产物仅写入 `logs/`,按需从后台查看: + +```text +logs/instruction_extract.json +logs/field_extract_result.json +logs/merged_fields.json +logs/traceability.json +logs/doc_adapter_result.json +``` + +这些 JSON 产物写入 `RegulatoryInfoPackageArtifact`,但不作为用户主下载。 + +### 12.3 zip 打包 + +zip 文件名: + +```text +第1章 监管信息(预生成版).zip +``` + +规则: + +| 场景 | 是否进入 zip | +| --- | --- | +| 文件状态 `success` | 是 | +| 文件状态 `fallback_success` | 是 | +| 文件状态 `failed` | 否 | +| 文件状态 `skipped` | 否 | + +若 `CH1.9 .doc` 兜底 `.docx` 成功,zip 中放入: + +```text +CH1.9 产品申报前沟通的说明.docx +``` + +--- + +## 十三、工作流详细设计 + +### 13.1 批次创建 + +```python +def create_regulatory_info_package_batch( + *, + conversation: Conversation, + user, + trigger_message: Message | None = None, + source_attachment: FileAttachment | None = None, + source_summary_batch: FileSummaryBatch | None = None, + source_summary_item_id: int | None = None, +) -> RegulatoryInfoPackageBatch: +``` + +创建后初始化 `REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS`。 + +### 13.2 执行器 + +```python +class RegulatoryInfoPackageWorkflowExecutor: + def run(self) -> None: ... + def _nodes(self): ... + def _run_node(self, node: WorkflowNodeRun) -> None: ... + def _execute_node(self, node: WorkflowNodeRun) -> None: ... +``` + +节点执行: + +| 节点 | 关键动作 | +| --- | --- | +| prepare | 确认说明书,或 waiting_user | +| template_copy | 复制 7 个模板 | +| template_audit | 审计模板字段 Tag/占位符,记录缺失和降级策略 | +| text_extract | 抽取说明书章节和表格 | +| field_extract | 规则 + LLM 并行抽取 | +| field_merge | 合并字段、高亮决策 | +| generate_docs | 多线程生成单文件 | +| highlight_review_items | 若生成策略已完成高亮,该节点记录确认结果即可 | +| trace_export | 写 Excel 和 logs JSON | +| zip_export | 打包成功/兜底成功文件 | +| notify | 写专项通知并调用统一通知 | +| completed | 写助手摘要 | + +### 13.3 状态落定 + +| 条件 | 状态 | +| --- | --- | +| zip 成功且 7 个文件均 success/fallback_success | success | +| zip 成功但有 failed/skipped | partial_success | +| zip 失败但至少一个单文件成功 | partial_success | +| 全部文件失败或关键输入缺失 | failed | +| 多说明书候选等待确认 | waiting_user | + +--- + +## 十四、路由与接口详细设计 + +### 14.1 skill_router.py + +增加: + +| 项 | 内容 | +| --- | --- | +| ROUTE_ACTIONS | 加入 `regulatory_info_package` | +| SkillRoute 属性 | `starts_regulatory_info_package` | +| deterministic route | 命中触发关键词直接返回 | +| LLM prompt | action 列表加入 `regulatory_info_package` | + +### 14.2 services.py + +`stream_message` 增加分支: + +1. 调用 `select_instruction_input(conversation, content)`。 +2. 若多候选,回复反问,不启动工作流。 +3. 若无候选,回复请上传说明书。 +4. 若唯一候选,创建批次并启动工作流。 +5. SSE 发送 `workflow_started`。 + +### 14.3 views.py + +接口: + +```text +GET /api/review-agent/regulatory-info-package/health/ +POST /api/review-agent/regulatory-info-package/start/ +GET /api/review-agent/regulatory-info-package//status/ +POST /api/review-agent/regulatory-info-package//select-input/ +``` + +`status` 返回: + +| 字段 | 说明 | +| --- | --- | +| batch | 状态、产品名、缺失/LLM-only/冲突数量 | +| nodes | 节点状态 | +| generated_files | 7 个文件成功/失败/兜底状态 | +| exports | zip、单文件、Excel 下载 | +| risk_notes | 风险提示 | +| notifications | 通知 | + +zip 不需要 `is_primary` 字段,前端或摘要按返回顺序把 zip 放首位。 + +--- + +## 十五、助手摘要设计 + +完成消息结构: + +```markdown +已生成第1章监管信息材料包。 + +批次号:RIP-... +产品名称:... +状态:success / partial_success + +主下载:[第1章 监管信息(预生成版).zip](...) + +| 文件 | 状态 | 下载/原因 | +| --- | --- | --- | +| CH1.2 监管信息目录.docx | 成功 | 下载 | +| CH1.9 产品申报前沟通的说明.docx | 兜底成功 | 下载 | +| CH1.11.1 符合标准的清单.docx | 失败 | 失败原因 | + +待确认:缺失项 X 个,LLM复核项 Y 个,冲突项 Z 个。 +``` + +要求: + +| 要求 | 说明 | +| --- | --- | +| zip 首位 | zip 链接必须在单文件列表之前 | +| 失败可见 | 失败文件展示状态和原因,无下载链接 | +| 兜底提示 | `.doc -> .docx` 时显示“兜底成功” | +| 待确认摘要 | 展示 missing、llm_only、conflict 数量 | + +--- + +## 十六、前端详细设计 + +### 16.1 模板 + +`templates/home.html` 增加工具 chip: + +```html + +``` + +`summaryPanel` 增加: + +```html +data-regulatory-info-package-status-url-template="/api/review-agent/regulatory-info-package/__batch_id__/status/" +``` + +### 16.2 app.js + +增加: + +| 位置 | 处理 | +| --- | --- | +| workflow type 判断 | 支持 `regulatory_info_package` | +| 状态 URL 选择 | 使用 `data-regulatory-info-package-status-url-template` | +| 终态判断 | success、partial_success、failed、waiting_user | +| 导出展示 | 直接按 exports 返回顺序展示,zip 在后端排首位 | + +### 16.3 不做选择 UI + +多说明书候选时,本期不做弹窗。通过对话反问用户确认文件名。 + +--- + +## 十七、导出下载权限 + +`file_summary.views._export_for_user` 增加: + +```python +if exported.workflow_type == "regulatory_info_package": + allowed = RegulatoryInfoPackageBatch.objects.filter( + pk=exported.workflow_batch_id, + conversation__user=user, + is_deleted=False, + ).exists() + return exported if allowed else None +``` + +下载 content type 增加 zip 和 `.doc` 后缀判断。 + +--- + +## 十八、通知详细设计 + +`notifier.py`: + +```python +def notify_completion(batch: RegulatoryInfoPackageBatch, exports: list[ExportedSummaryFile]) -> RegulatoryInfoPackageNotificationRecord: +``` + +处理: + +| 步骤 | 说明 | +| --- | --- | +| 创建专项通知记录 | 写 `RegulatoryInfoPackageNotificationRecord` | +| 调用统一通知 | `dispatch_workflow_notification(build_regulatory_info_package_context(batch))` | +| 捕获异常 | 通知失败写记录和 risk_notes,不影响批次下载 | + +--- + +## 十九、测试详细设计 + +| 测试文件 | 覆盖 | +| --- | --- | +| test_regulatory_info_package_models.py | 三张表、zip export type、基础关联 | +| test_regulatory_info_package_trigger.py | 固定关键词与 LLM action | +| test_regulatory_info_package_input_select.py | 文件名模糊匹配、active 附件、多候选反问 | +| test_regulatory_info_package_template_config.py | YAML 加载、模板缺失、code 唯一 | +| test_regulatory_info_package_instruction_extract.py | 说明书章节和组成表抽取 | +| test_regulatory_info_package_field_extract.py | 规则抽取、LLM 三次重试、失败降级 | +| test_regulatory_info_package_field_merge.py | missing、llm_only、conflict | +| test_regulatory_info_package_docx_writer.py | 替换、表格填充、黄底、红字 | +| test_regulatory_info_package_legacy_doc.py | adapter 探测、docx 兜底、失败状态 | +| test_regulatory_info_package_package_generate.py | 7 文件生成结果、多线程异常隔离 | +| test_regulatory_info_package_traceability.py | Excel 追溯和 logs JSON | +| test_regulatory_info_package_zip.py | zip 只包含 success/fallback_success | +| test_regulatory_info_package_workflow.py | 节点流转、partial_success、waiting_user | +| test_regulatory_info_package_views.py | start/status/download 权限 | +| test_regulatory_info_package_frontend.py | chip、卡片、状态 URL | + +--- + +## 二十、异常处理矩阵 + +| 异常 | 批次状态 | 处理 | +| --- | --- | --- | +| 无说明书 | waiting_user 或不创建批次 | 提示上传说明书 | +| 多候选无法匹配 | waiting_user 或不创建批次 | 反问确认文件名 | +| 模板缺失 | failed | 列出缺失模板 | +| 规则抽取失败 | partial_success/continue | 使用 LLM 结果 | +| LLM 三次失败 | continue | 使用规则结果,写 risk_notes | +| 产品名缺失 | partial_success | 写 `/` 黄底,继续生成 zip | +| 单个 docx 文件生成失败 | partial_success | 不进入 zip,摘要展示失败 | +| CH1.9 doc 原生失败但 docx 兜底成功 | success/partial_success | 状态 fallback_success,进入 zip | +| CH1.9 doc 和 docx 兜底均失败 | partial_success | 不进入 zip,摘要展示失败 | +| traceability.xlsx 失败 | partial_success | 不阻断 zip | +| zip 失败 | partial_success | 保留单文件下载 | +| 通知失败 | 不影响主状态 | 写通知失败和 risk_notes | + +--- + +## 二十一、设计结论 + +| 编号 | 结论 | +| --- | --- | +| D1 | 详细设计文档路径为 `docs/4.详细设计/5.第1章监管信息材料包生成.md` | +| D2 | 模型集中在 `review_agent/models.py`,业务模块为 `review_agent/regulatory_info_package/` | +| D3 | `.doc` 采用能力驱动策略:探测 Word COM/UNO 等原生能力,有能力时优先原生处理 | +| D4 | `.doc` 无原生能力或原生失败时允许 `.docx` 兜底;兜底文件名为 `CH1.9 产品申报前沟通的说明.docx` | +| D5 | zip 只包含成功或兜底成功文件,失败文件不进入 zip | +| D6 | LLM 最多重试 3 次,失败后使用规则结果继续 | +| D7 | 缺失和 LLM-only 黄底,冲突黄底红字 | +| D8 | 产品列表使用 `ProductListRow`,货号固定 `/` 黄底 | +| D9 | 标准清单只复用现有知识库能力,不新增独立 RAG 流程 | +| D10 | 前端最小接入,不做说明书选择弹窗 | +| D11 | 追溯 Excel 可下载,JSON 只放后台 logs | +| D12 | 本期不新增字段级数据库表 | +| D13 | 工作流串行,文档生成节点内部可多线程 | +| D14 | 模板优先字段化,正式填充路径使用内容控件 Tag 或稳定占位符,行标签定位仅作为兜底 | +| D15 | 本轮只产出详细设计,不写代码、不生成迁移 | diff --git a/docs/5.开发计划/1.自动汇总-前端线框图.md b/docs/5.开发计划/1.自动汇总-前端线框图.md new file mode 100644 index 0000000..3bb0ed1 --- /dev/null +++ b/docs/5.开发计划/1.自动汇总-前端线框图.md @@ -0,0 +1,74 @@ +# 自动汇总前端线框图 + +## 评审目标 + +在实现三栏页面前,先确认审核智能体工作台的信息架构、右侧文件汇总面板、工作流状态展示和移动端降级方式。 + +## 桌面端布局 + +```mermaid +flowchart LR + A["左栏:会话列表
新对话 / 搜索 / 历史会话"] --> B["中栏:聊天区
顶部导航 / 消息流 / 输入框"] + B --> C["右栏:文件汇总面板"] + C --> C1["上半区:上传区
拖拽上传 / 选择文件 / 上传状态"] + C --> C2["中段:当前对话附件
文件名 / 版本 / 大小 / 状态 / 删除"] + C --> C3["下半区:工作流卡片
批次号 / 节点进度 / 下载入口"] +``` + +## 右侧面板结构 + +```mermaid +flowchart TB + P["文件汇总面板"] --> U["上传拖拽区"] + U --> U0["无附件:提示上传文件或压缩包"] + U --> U1["上传中:显示文件名和处理中状态"] + U --> U2["上传失败:展示错误并允许重试"] + P --> L["附件列表"] + L --> L1["active 版本优先展示"] + L --> L2["历史版本保留展示"] + L --> L3["逻辑删除后从默认候选移除"] + P --> W["工作流卡片列表"] + W --> W1["运行中:节点逐项更新"] + W --> W2["成功:展示 Markdown/Excel 下载"] + W --> W3["失败:展示失败节点和错误说明"] +``` + +## 工作流状态流转 + +```mermaid +stateDiagram-v2 + [*] --> Pending: 用户上传附件 + Pending --> Running: 发送自动汇总提示词 + Running --> Extracting: 固化附件 + Extracting --> Scanning: 解压完成或跳过 + Scanning --> Counting: 生成文件清单 + Counting --> Detecting: 页数统计完成 + Detecting --> Reporting: 产品名识别完成 + Reporting --> Success: 生成报告与下载 + Running --> Failed: 批次级异常 + Extracting --> Failed: 解压安全检查失败 + Reporting --> Failed: 报告生成失败 + Success --> Restored: 刷新页面后状态恢复 + Failed --> Restored: 刷新页面后状态恢复 +``` + +## 移动端布局 + +```mermaid +flowchart TB + M["移动端工作台"] --> T["顶部:侧栏按钮 / 当前页面 / 用户菜单"] + T --> Chat["聊天区优先展示"] + Chat --> Composer["底部输入框"] + T --> Drawer["会话侧栏抽屉"] + Chat --> Panel["文件汇总面板下移或折叠"] + Panel --> Upload["上传区"] + Panel --> Workflow["工作流卡片"] +``` + +## 关键评审点 + +- 桌面端保持左侧会话、中间聊天、右侧文件汇总三栏,不改变现有聊天主路径。 +- 右侧面板上半部分用于上传和附件列表,下半部分用于批次工作流卡片。 +- 工作流卡片节点顺序固定为:附件固化、压缩包解压、文件扫描、页数统计、产品识别、报告输出、完成。 +- 助手消息中的文件汇总结果使用安全 Markdown 渲染,用户消息仍按纯文本转义。 +- 移动端优先保证聊天可用,文件汇总面板折叠或下移,不能遮挡输入框。 diff --git a/docs/5.开发计划/1.自动汇总.md b/docs/5.开发计划/1.自动汇总.md new file mode 100644 index 0000000..bd4aee2 --- /dev/null +++ b/docs/5.开发计划/1.自动汇总.md @@ -0,0 +1,634 @@ +# 自动汇总文件夹文件目录与页数流程开发计划 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/1.自动汇总.md | +| 功能设计文档 | docs/2.功能设计/1.自动汇总.md | +| 详细设计文档 | docs/3.详细设计/1.自动汇总.md | +| 数据库设计文档 | docs/4.数据库设计/1.自动汇总.md | +| 功能名称 | 自动汇总文件夹文件目录与页数 | +| 所属模块 | 审核智能体 review_agent | +| 执行方式 | 单人开发 + Codex 流水线自动化执行 | +| 计划日期 | 2026-06-05 | +| 计划版本 | V1.0 | + +--- + +## 一、开发计划目标 + +本开发计划用于指导 Codex 按阶段自动完成“自动汇总文件夹文件目录与页数”功能开发。任务拆分按可交付阶段组织,每个任务都需要具备明确目标、涉及文件、前置依赖、开发步骤、验收标准、验证命令和 Codex 执行提示。 + +本功能不按 MVP 缩减范围,必须按需求分析、功能设计、详细设计、数据库设计中的全部范围完成。 + +--- + +## 二、已确认开发规则 + +| 规则项 | 内容 | +| --- | --- | +| 拆分方式 | 按可交付阶段拆分 | +| 任务粒度 | 每个任务写到可直接交给 Codex 执行 | +| 执行对象 | 一个开发者使用 Codex 流水线自动化执行 | +| 单任务范围 | 尽量控制在 1 到 3 类文件 | +| Codex 提示 | 每个任务都提供“Codex 执行提示” | +| 功能范围 | 必须完成全部需求,不允许降级为最小闭环 | +| 前端验证 | 使用 Playwright 做真实浏览器端到端测试 | +| 测试数据 | 测试代码中可动态创建登录用户和临时文件 | +| Git 提交 | 每个阶段完成并验证通过后提交一次 | +| 提交摘要 | 使用执行机器上的 `git-commit-summary` skill | +| 分支规则 | 从 `V2` 创建日期 + 中文功能名分支,完成后合并回 `V2` | + +--- + +## 三、总体验收标准 + +| 类别 | 完成标准 | +| --- | --- | +| 数据库 | 7 张 `ra_` 表全部通过 Django migration 落库,约束、索引、枚举齐全 | +| 上传 | 当前对话右侧上传区支持多文件和压缩包上传,上传即存储,附件不跨对话 | +| 触发 | 用户发送命中提示词后才启动自动汇总工作流,普通对话不误触发 | +| 工作流 | 后台异步执行,节点状态可实时更新,事件可持久化和恢复 | +| 解压 | 支持 zip、7z、rar,解压安全检查必须完成 | +| 统计 | 支持 pdf、doc、docx、xls、xlsx、ppt、pptx,失败重试 3 次,失败不阻断批次 | +| 输出 | 生成 Markdown 报告、Excel 明细,对话框展示 Markdown 简表和下载链接 | +| 前端 | 三栏布局、上方拖拽上传、下方工作流卡片、Markdown 表格渲染正常 | +| 存档 | 批次、附件、文件明细、节点、事件、导出文件全部入库 | +| 标题 | 识别到产品名后按规则更新对话标题 | +| 权限 | 上传、查询、下载都校验当前用户和当前对话 | +| 测试 | 单元、接口、集成、Playwright 端到端测试全部覆盖 | +| 部署 | requirements 可安装,Docker 部署说明包含 7z/p7zip,rar/7z 解压验证通过 | + +--- + +## 四、阶段总览 + +| 阶段 | 名称 | 目标 | 阶段验收 | +| --- | --- | --- | --- | +| P0 | 流水线准备 | 建立开发分支,确认依赖、规范和现状 | 分支创建完成,开发前检查通过 | +| P1 | 数据模型与迁移 | 完成 7 张 ra_ 表 ORM 与 migration | SQLite 可建表,模型约束正确 | +| P2 | 上传与对话绑定 | 实现上传即存储、同名版本和附件权限 | 上传接口可用,附件不跨对话 | +| P3 | 工作流触发与后台执行 | 实现提示词触发、批次创建、后台节点执行和事件持久化 | 命中提示词可启动工作流,状态可查询 | +| P4 | Skill 与文件处理能力 | 实现解压、扫描、页数统计、重试和产品名识别 | 支持格式全部进入处理流程 | +| P5 | 报告生成与下载 | 实现 Markdown 报告、Excel 导出、下载权限和助手消息 | 可下载报告,数据库留痕完整 | +| P6 | 前端三栏与工作流卡片 | 实现右侧上传区、工作流卡片、SSE 更新和 Markdown 渲染 | Playwright 验证前端主流程 | +| P7 | 测试、部署与总体验收 | 补齐自动化测试、端到端测试、Docker 说明和最终合并 | 全部测试通过,合并回 V2 | + +--- + +## 五、P0 流水线准备 + +### FS-P0-001 创建开发分支并检查现状 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | Git / 准备 | +| 前置任务 | 无 | +| 涉及文件 | 无固定文件 | +| 目标 | 从 `V2` 分支创建日期 + 中文功能名开发分支,并确认工作区状态 | +| 开发步骤 | 1. 切换到 `V2`;2. 拉取或确认本地最新状态;3. 创建 `codex/YYYYMMDD-自动汇总文件目录页数` 分支;4. 检查 `git status`;5. 确认已有设计文档存在 | +| 验收标准 | 开发分支创建成功;工作区变更来源清楚;不会覆盖用户已有未提交改动 | +| 验证命令 | `git branch --show-current`; `git status --short` | +| Codex 执行提示 | 请从 `V2` 创建 `codex/YYYYMMDD-自动汇总文件目录页数` 开发分支,检查当前工作区状态,不要回滚用户已有变更。 | + +### FS-P0-002 补充依赖清单与部署前置说明 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 依赖 / 部署准备 | +| 前置任务 | FS-P0-001 | +| 涉及文件 | `requirements.txt`、部署说明文档、前端静态资源引入位置 | +| 目标 | 增加文件解析与导出所需 Python 依赖,并说明 rar/7z 的系统依赖 | +| 开发步骤 | 1. 在 `requirements.txt` 增加 `pypdf`、`python-docx`、`python-pptx`、`openpyxl`、`xlrd`、`olefile`、`py7zr`;2. 在前端任务中明确 `marked + DOMPurify` 通过模板或静态资源引入;3. 在部署说明中写明 Docker 需要安装 7z/p7zip;4. 明确不强制依赖 LibreOffice | +| 验收标准 | Python 依赖可安装;部署说明明确 rar 依赖系统 7z/p7zip;未引入 LibreOffice 强依赖 | +| 验证命令 | `pip install -r requirements.txt` | +| Codex 执行提示 | 请按详细设计补充轻量依赖,并在部署说明中写清 Docker 需安装 7z/p7zip 支持 rar/7z,禁止把 LibreOffice 作为必需依赖。 | + +--- + +## 六、P1 数据模型与迁移 + +### FS-P1-001 新增文件汇总 ORM 模型 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 数据库 / 后端 | +| 前置任务 | P0 | +| 涉及文件 | `review_agent/models.py` | +| 目标 | 新增文件汇总相关 7 个模型和状态枚举 | +| 开发步骤 | 1. 定义 `FileAttachment`;2. 定义 `FileSummaryBatch`;3. 定义 `FileSummaryBatchAttachment`;4. 定义 `FileSummaryItem`;5. 定义 `WorkflowNodeRun`;6. 定义 `WorkflowEvent`;7. 定义 `ExportedSummaryFile`;8. 使用 Django `TextChoices` 管理枚举 | +| 验收标准 | 模型字段、关联、默认值、`db_table`、`indexes`、`constraints` 与数据库设计一致 | +| 验证命令 | `python manage.py check` | +| Codex 执行提示 | 请按 `docs/4.数据库设计/1.自动汇总.md` 在 `review_agent/models.py` 新增 7 个 `ra_` 表模型,使用 Django ORM、TextChoices、短表名、索引和唯一约束。 | + +### FS-P1-002 生成并验证数据库迁移 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 数据库 / 迁移 | +| 前置任务 | FS-P1-001 | +| 涉及文件 | `review_agent/migrations/` | +| 目标 | 生成 migration 并验证 SQLite 可落表 | +| 开发步骤 | 1. 执行 `makemigrations`;2. 检查 migration 是否只包含本功能相关模型;3. 执行 `migrate`;4. 检查表结构和索引 | +| 验收标准 | migration 可执行;SQLite 中生成 7 张 `ra_` 表;约束和索引生效 | +| 验证命令 | `python manage.py makemigrations review_agent`; `python manage.py migrate`; `python manage.py check` | +| Codex 执行提示 | 请为文件汇总模型生成 Django migration 并执行迁移验证,确保 SQLite 下 7 张 `ra_` 表均可创建。 | + +### FS-P1-003 增加模型级测试 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 测试 / 数据库 | +| 前置任务 | FS-P1-002 | +| 涉及文件 | `tests/test_file_summary_models.py` | +| 目标 | 覆盖附件版本、批次绑定、唯一约束和权限查询基础逻辑 | +| 开发步骤 | 1. 测试同一对话同名附件版本号递增;2. 测试 active 版本切换;3. 测试批次绑定附件唯一;4. 测试同批次 relative_path 唯一;5. 测试导出文件能追溯到用户和对话 | +| 验收标准 | 模型测试全部通过,关键约束失败时能暴露错误 | +| 验证命令 | `pytest tests/test_file_summary_models.py` | +| Codex 执行提示 | 请新增模型级测试,覆盖文件汇总表的版本、绑定、唯一约束和对话隔离规则。 | + +### P1 阶段提交规则 + +| 项目 | 内容 | +| --- | --- | +| 阶段验证 | `python manage.py check`; `pytest tests/test_file_summary_models.py` | +| 提交动作 | 调用 `git-commit-summary` 生成提交摘要并提交 | +| Codex 执行提示 | P1 验证通过后,请调用 `git-commit-summary` 分析本阶段变更,并按其输出提交到当前开发分支。 | + +--- + +## 七、P2 上传与对话绑定 + +### FS-P2-001 实现附件存储服务 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 存储 | +| 前置任务 | P1 | +| 涉及文件 | `review_agent/file_summary/storage.py`、`review_agent/file_summary/constants.py` | +| 目标 | 实现上传文件保存、版本号生成、存储目录生成和逻辑删除基础能力 | +| 开发步骤 | 1. 创建 `file_summary` 目录;2. 实现按 `user/conversation/attachments` 保存文件;3. 实现同名附件版本递增;4. 新版本设为 active 并关闭旧 active;5. 实现路径安全处理 | +| 验收标准 | 上传文件保存到受控目录;附件记录绑定当前用户和对话;同名多版本不覆盖 | +| 验证命令 | `pytest tests/test_file_summary_storage.py` | +| Codex 执行提示 | 请实现文件汇总附件存储服务,保证上传即存储、同名多版本、当前对话绑定和路径安全。 | + +### FS-P2-002 实现附件上传接口 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 接口 | +| 前置任务 | FS-P2-001 | +| 涉及文件 | `review_agent/file_summary/views.py`、`review_agent/file_summary/urls.py`、`config/urls.py` | +| 目标 | 新增对话附件上传接口,支持多文件和压缩包上传 | +| 开发步骤 | 1. 新增 `POST /api/review-agent/conversations/{conversation_id}/attachments/`;2. 校验 conversation 属于 request.user;3. 保存多个文件;4. 返回 attachment 列表;5. 接入 URL | +| 验收标准 | 当前用户只能向自己的对话上传;接口返回附件 ID、文件名、大小、版本和状态 | +| 验证命令 | `pytest tests/test_file_summary_views.py -k upload` | +| Codex 执行提示 | 请新增对话附件上传 API,支持一次上传多个文件,所有附件必须绑定当前 Conversation,禁止跨用户上传。 | + +### FS-P2-003 实现附件列表和删除接口 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 接口 | +| 前置任务 | FS-P2-002 | +| 涉及文件 | `review_agent/file_summary/views.py`、`review_agent/file_summary/urls.py` | +| 目标 | 支持前端右侧上传区展示当前对话附件,并允许逻辑删除 | +| 开发步骤 | 1. 新增当前对话附件列表接口;2. 返回 active 和历史版本信息;3. 新增附件逻辑删除接口;4. 删除时设置 `upload_status=deleted`、`is_active=false` | +| 验收标准 | 附件列表只返回当前对话文件;逻辑删除不影响历史批次追溯 | +| 验证命令 | `pytest tests/test_file_summary_views.py -k attachment` | +| Codex 执行提示 | 请实现当前对话附件列表和逻辑删除接口,支持同名版本展示,删除不得物理移除历史批次需要的文件。 | + +### P2 阶段提交规则 + +| 项目 | 内容 | +| --- | --- | +| 阶段验证 | `pytest tests/test_file_summary_storage.py tests/test_file_summary_views.py -k "upload or attachment"` | +| 提交动作 | 调用 `git-commit-summary` 生成提交摘要并提交 | +| Codex 执行提示 | P2 验证通过后,请调用 `git-commit-summary` 分析本阶段变更,并按其输出提交到当前开发分支。 | + +--- + +## 八、P3 工作流触发与后台执行 + +### FS-P3-001 实现提示词触发判断 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 意图识别 | +| 前置任务 | P2 | +| 涉及文件 | `review_agent/file_summary/services/workflow_trigger.py`、`review_agent/services.py` | +| 目标 | 根据提示词决定是否启动自动汇总工作流 | +| 开发步骤 | 1. 定义触发关键词;2. 判断当前对话是否存在可用 active 附件;3. 命中时返回 workflow 类型;4. 未命中走普通 LLM;5. 命中但无附件时返回提示 | +| 验收标准 | “自动汇总”“文件目录”“页数”等关键词可触发;普通对话不误触发 | +| 验证命令 | `pytest tests/test_file_summary_trigger.py` | +| Codex 执行提示 | 请实现自动汇总工作流触发判断,只有当前对话存在可用附件且提示词命中关键词时才启动工作流。 | + +### FS-P3-002 实现批次创建与附件固化 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 工作流 | +| 前置任务 | FS-P3-001 | +| 涉及文件 | `review_agent/file_summary/workflow.py`、`review_agent/file_summary/storage.py` | +| 目标 | 用户消息触发时创建 FileSummaryBatch,并固化本次使用的附件版本 | +| 开发步骤 | 1. 创建批次编号;2. 创建 `FileSummaryBatch`;3. 绑定 active 附件到中间表;4. 标记附件为 bound;5. 创建初始节点记录 | +| 验收标准 | 同一对话可多次汇总;历史批次绑定历史附件版本;不会读取其他对话文件 | +| 验证命令 | `pytest tests/test_file_summary_workflow.py -k batch` | +| Codex 执行提示 | 请实现批次创建和附件版本固化,确保每次汇总只读取本批次绑定的附件。 | + +### FS-P3-003 实现 WorkflowEvent 与 SSE 事件查询 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / SSE | +| 前置任务 | FS-P3-002 | +| 涉及文件 | `review_agent/file_summary/events.py`、`review_agent/file_summary/views.py` | +| 目标 | 持久化工作流事件,并支持前端按 batch 监听和断点续传 | +| 开发步骤 | 1. 实现事件写入;2. 实现 SSE 格式化;3. 新增 `GET /api/review-agent/file-summary/{batch_id}/events/?after=`;4. 新增批次状态查询接口;5. 校验用户权限 | +| 验收标准 | 节点事件可入库;SSE 可返回事件流;页面刷新可通过状态接口恢复 | +| 验证命令 | `pytest tests/test_file_summary_views.py -k "event or status"` | +| Codex 执行提示 | 请实现工作流事件持久化、事件 SSE 接口和批次状态查询接口,所有查询必须校验当前用户权限。 | + +### FS-P3-004 实现轻量后台工作流执行器 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 工作流 | +| 前置任务 | FS-P3-003 | +| 涉及文件 | `review_agent/file_summary/workflow.py`、`review_agent/file_summary/skills/` | +| 目标 | 实现串行节点图执行器,后台异步执行并更新节点状态 | +| 开发步骤 | 1. 定义节点顺序;2. 实现后台线程启动;3. 实现节点开始、成功、失败、跳过状态;4. 每个节点写入 WorkflowNodeRun;5. 每个节点发送 WorkflowEvent | +| 验收标准 | 命中提示词后可后台创建并推进节点;节点状态可查询;异常能标记批次失败 | +| 验证命令 | `pytest tests/test_file_summary_workflow.py -k executor` | +| Codex 执行提示 | 请实现轻量 WorkflowExecutor,按节点图异步执行文件汇总流程,实时写入节点状态和事件。 | + +### FS-P3-005 接入现有流式聊天接口 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 对话 | +| 前置任务 | FS-P3-004 | +| 涉及文件 | `review_agent/services.py`、`review_agent/views.py` | +| 目标 | 在现有 `stream_chat` 流程中按需启动自动汇总工作流 | +| 开发步骤 | 1. 用户消息入库后判断触发;2. 命中时创建批次并启动后台;3. SSE meta 返回 workflow 信息;4. 对话中返回“已启动工作流”类助手消息或后续由报告生成写入结果;5. 未命中时保持原 LLM 流式逻辑 | +| 验收标准 | 普通聊天不受影响;自动汇总触发后前端可拿到 batch_id;无附件时提示用户先上传 | +| 验证命令 | `pytest tests/test_chat.py tests/test_file_summary_workflow.py -k trigger` | +| Codex 执行提示 | 请把自动汇总触发接入现有流式聊天接口,保证普通 LLM 对话兼容,命中工作流时返回 workflow meta。 | + +### P3 阶段提交规则 + +| 项目 | 内容 | +| --- | --- | +| 阶段验证 | `pytest tests/test_file_summary_trigger.py tests/test_file_summary_workflow.py tests/test_file_summary_views.py` | +| 提交动作 | 调用 `git-commit-summary` 生成提交摘要并提交 | +| Codex 执行提示 | P3 验证通过后,请调用 `git-commit-summary` 分析本阶段变更,并按其输出提交到当前开发分支。 | + +--- + +## 九、P4 Skill 与文件处理能力 + +### FS-P4-001 实现 Skill 基类与注册表 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / Skill | +| 前置任务 | P3 | +| 涉及文件 | `review_agent/file_summary/skills/base.py`、`review_agent/file_summary/skills/registry.py`、`review_agent/file_summary/schemas.py` | +| 目标 | 建立项目内 Skill 注册与调用机制,后续可迁移 MCP | +| 开发步骤 | 1. 定义 `WorkflowContext`;2. 定义 `SkillResult`;3. 定义 `BaseSkill`;4. 实现 `SkillRegistry`;5. 支持按名称获取和执行 Skill | +| 验收标准 | 工作流执行器通过注册表调用 Skill;Skill 输入输出保持 JSON 友好 | +| 验证命令 | `pytest tests/test_file_summary_skills.py -k registry` | +| Codex 执行提示 | 请实现文件汇总 Skill 基类、上下文、统一返回结构和注册表,使工作流节点能按需加载 Skill。 | + +### FS-P4-002 实现压缩包解压 Skill + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 文件处理 | +| 前置任务 | FS-P4-001 | +| 涉及文件 | `review_agent/file_summary/services/archive.py`、`review_agent/file_summary/skills/archive_extract.py` | +| 目标 | 支持 zip、7z、rar 解压,并完成路径穿越防护 | +| 开发步骤 | 1. 实现压缩包识别;2. 使用 `zipfile` 解压 zip;3. 使用 `py7zr` 解压 7z;4. 使用系统 `7z` 解压 rar;5. 检查解压目标路径必须在批次工作目录内;6. 解压失败标记批次失败 | +| 验收标准 | zip、7z、rar 均进入解压流程;恶意路径压缩包被拒绝;解压目录保留层级 | +| 验证命令 | `pytest tests/test_file_summary_archive.py` | +| Codex 执行提示 | 请实现压缩包解压服务和 Skill,必须支持 zip、7z、rar,并对所有解压路径做 target_dir 内部校验。 | + +### FS-P4-003 实现文件清单扫描 Skill + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 文件处理 | +| 前置任务 | FS-P4-002 | +| 涉及文件 | `review_agent/file_summary/services/inventory.py`、`review_agent/file_summary/skills/file_inventory.py` | +| 目标 | 扫描解压目录或散装文件,生成 FileSummaryItem 明细 | +| 开发步骤 | 1. 识别扫描根目录;2. 递归遍历文件;3. 生成相对路径;4. 生成目录层级;5. 标记支持、不支持、空文件或跳过状态;6. 按目录顺序生成 file_index | +| 验收标准 | 文件明细保留目录层级;散装文件进入同一批次根;relative_path 唯一 | +| 验证命令 | `pytest tests/test_file_summary_inventory.py` | +| Codex 执行提示 | 请实现文件清单扫描服务和 Skill,保留目录层级,生成文件序号、相对路径、文件类型和初始统计状态。 | + +### FS-P4-004 实现页数统计服务 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 文件解析 | +| 前置任务 | FS-P4-003 | +| 涉及文件 | `review_agent/file_summary/services/page_count.py` | +| 目标 | 支持 pdf、doc、docx、xls、xlsx、ppt、pptx 页数或数量统计 | +| 开发步骤 | 1. pdf 使用 `pypdf` 统计页面;2. docx 使用 `python-docx` 读取内置页数属性;3. doc 使用 `olefile` 读取 OLE 元数据;4. xlsx 使用 `openpyxl` 统计工作表;5. xls 使用 `xlrd` 统计工作表;6. pptx 使用 `python-pptx` 统计幻灯片;7. ppt 使用 `olefile` 读取元数据;8. 无可靠页数时标记 uncertain | +| 验收标准 | 7 类格式全部有处理分支;读不到页数不崩溃;状态区分 success、failed、unsupported、uncertain | +| 验证命令 | `pytest tests/test_file_summary_page_count.py` | +| Codex 执行提示 | 请实现页数统计服务,覆盖 pdf/doc/docx/xls/xlsx/ppt/pptx,老格式读不到可靠页数时标记 uncertain,不允许中断批次。 | + +### FS-P4-005 实现页数统计 Skill 与三次重试 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / Skill | +| 前置任务 | FS-P4-004 | +| 涉及文件 | `review_agent/file_summary/skills/document_page_count.py`、`review_agent/file_summary/services/page_count.py` | +| 目标 | 对每个支持文件执行页数统计,失败最多重试 3 次 | +| 开发步骤 | 1. 遍历 FileSummaryItem;2. 支持类型调用 page_count 服务;3. 失败重试 3 次;4. 更新 retry_count、statistics_status、page_count、error_message;5. 更新节点进度事件;6. 汇总批次数量 | +| 验收标准 | 单文件失败不阻断其他文件;重试事件可记录;批次统计字段更新正确 | +| 验证命令 | `pytest tests/test_file_summary_page_count.py -k retry` | +| Codex 执行提示 | 请实现文档页数统计 Skill,对单文件解析失败最多重试 3 次,仍失败则记录异常并继续处理其他文件。 | + +### FS-P4-006 实现产品名识别 Skill 与会话标题更新 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 识别 | +| 前置任务 | FS-P4-005 | +| 涉及文件 | `review_agent/file_summary/services/product_detect.py`、`review_agent/file_summary/skills/product_detect.py` | +| 目标 | 从目录名、文件名和少量元数据中识别产品名,并按规则更新对话标题 | +| 开发步骤 | 1. 优先使用顶层目录名;2. 从含“产品”“试剂盒”“说明书”等关键词的文件名提取;3. 尝试读取 docx/PDF 元数据 title;4. 写入 batch.product_name;5. 默认标题时更新 Conversation.title;6. 用户自定义标题不覆盖 | +| 验收标准 | 识别失败不阻断;识别成功后批次记录产品名;默认对话标题可更新为“产品名-文件汇总” | +| 验证命令 | `pytest tests/test_file_summary_product_detect.py` | +| Codex 执行提示 | 请实现产品名识别 Skill,从目录名、文件名和轻量元数据识别产品名,识别成功后按规则更新批次和对话标题。 | + +### P4 阶段提交规则 + +| 项目 | 内容 | +| --- | --- | +| 阶段验证 | `pytest tests/test_file_summary_skills.py tests/test_file_summary_archive.py tests/test_file_summary_inventory.py tests/test_file_summary_page_count.py tests/test_file_summary_product_detect.py` | +| 提交动作 | 调用 `git-commit-summary` 生成提交摘要并提交 | +| Codex 执行提示 | P4 验证通过后,请调用 `git-commit-summary` 分析本阶段变更,并按其输出提交到当前开发分支。 | + +--- + +## 十、P5 报告生成与下载 + +### FS-P5-001 实现 Markdown 报告生成 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 报告 | +| 前置任务 | P4 | +| 涉及文件 | `review_agent/file_summary/services/report.py`、`review_agent/file_summary/skills/summary_report.py` | +| 目标 | 生成完整 Markdown 报告和对话框展示简表 | +| 开发步骤 | 1. 构建统计摘要;2. 构建对话简表;3. 构建完整 Markdown 报告;4. 保存到批次 exports 目录;5. 创建 ExportedSummaryFile;6. 生成助手消息内容 | +| 验收标准 | Markdown 包含汇总信息、统计摘要、文件明细、异常清单、处理说明和下载链接占位 | +| 验证命令 | `pytest tests/test_file_summary_report.py -k markdown` | +| Codex 执行提示 | 请实现 Markdown 报告生成 Skill,完整报告和对话简表必须包含文件序号、目录层级、文件名、类型、页数、路径、状态、异常说明。 | + +### FS-P5-002 实现 Excel 导出 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 导出 | +| 前置任务 | FS-P5-001 | +| 涉及文件 | `review_agent/file_summary/services/export_excel.py`、`review_agent/file_summary/skills/excel_export.py` | +| 目标 | 生成 Excel 明细文件 | +| 开发步骤 | 1. 使用 `openpyxl` 创建 Workbook;2. 创建“汇总信息”Sheet;3. 创建“文件明细”Sheet;4. 写入状态、重试次数和异常说明;5. 保存到 exports 目录;6. 创建 ExportedSummaryFile | +| 验收标准 | Excel 可打开;至少包含两个工作表;字段与需求一致 | +| 验证命令 | `pytest tests/test_file_summary_report.py -k excel` | +| Codex 执行提示 | 请实现 Excel 导出 Skill,生成包含“汇总信息”和“文件明细”两个 Sheet 的汇总文件。 | + +### FS-P5-003 实现导出下载接口 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 下载 | +| 前置任务 | FS-P5-002 | +| 涉及文件 | `review_agent/file_summary/views.py`、`review_agent/file_summary/urls.py` | +| 目标 | 提供 Markdown 和 Excel 文件下载,并校验权限 | +| 开发步骤 | 1. 新增 `GET /api/review-agent/file-summary/exports/{export_id}/download/`;2. 校验 export -> batch -> conversation -> user;3. 返回文件流;4. 设置合适文件名;5. 文件不存在时返回错误 | +| 验收标准 | 当前用户可下载自己的导出文件;不能下载其他用户文件;下载链接可用于 Markdown | +| 验证命令 | `pytest tests/test_file_summary_views.py -k download` | +| Codex 执行提示 | 请实现导出文件下载接口,下载权限必须沿 export -> batch -> conversation -> user 校验。 | + +### FS-P5-004 完成报告 Skill 与工作流衔接 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 工作流 | +| 前置任务 | FS-P5-003 | +| 涉及文件 | `review_agent/file_summary/workflow.py`、`review_agent/file_summary/services/report.py` | +| 目标 | 工作流完成后写入助手消息,展示 Markdown 简表和真实下载链接 | +| 开发步骤 | 1. 报告和 Excel 导出完成后生成下载 URL;2. 替换对话简表中的下载链接;3. 创建 assistant Message;4. 标记 batch success;5. 发送 workflow_completed 事件 | +| 验收标准 | 工作流完成后对话中出现 Markdown 简表;下载链接可点击;批次状态成功 | +| 验证命令 | `pytest tests/test_file_summary_workflow.py -k report` | +| Codex 执行提示 | 请把 Markdown 报告、Excel 导出和工作流完成逻辑串起来,完成后向当前对话写入助手消息。 | + +### P5 阶段提交规则 + +| 项目 | 内容 | +| --- | --- | +| 阶段验证 | `pytest tests/test_file_summary_report.py tests/test_file_summary_views.py -k download tests/test_file_summary_workflow.py -k report` | +| 提交动作 | 调用 `git-commit-summary` 生成提交摘要并提交 | +| Codex 执行提示 | P5 验证通过后,请调用 `git-commit-summary` 分析本阶段变更,并按其输出提交到当前开发分支。 | + +--- + +## 十一、P6 前端三栏与工作流卡片 + +### FS-P6-001 改造页面为三栏布局 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 前端 / 布局 | +| 前置任务 | P5 | +| 涉及文件 | `templates/home.html`、实际静态 CSS 文件 | +| 目标 | 在现有对话页增加右侧第三栏,上半部分上传区,下半部分工作流卡片 | +| 开发步骤 | 1. 确认真实静态样式文件路径;2. 调整 workspace 结构;3. 增加 `workflow-panel`;4. 增加 `upload-dropzone`;5. 增加 `workflow-card-list`;6. 保证桌面和移动端不遮挡 | +| 验收标准 | 页面显示左侧会话、中间聊天、右侧上传/工作流三栏;移动端布局可用 | +| 验证命令 | `pytest tests/test_file_summary_e2e.py -k layout` 或 Playwright 对应命令 | +| Codex 执行提示 | 请把审核智能体页面改造成三栏布局,右侧上半部分为拖拽上传区,下半部分为工作流卡片列表,并保持现有聊天能力可用。 | + +### FS-P6-002 实现前端附件上传交互 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 前端 / 上传 | +| 前置任务 | FS-P6-001 | +| 涉及文件 | `static/js/app.js`、`templates/home.html`、实际静态 CSS 文件 | +| 目标 | 支持拖拽或选择多个文件上传,上传成功后展示附件列表 | +| 开发步骤 | 1. 绑定 dropzone;2. 支持点击选择文件;3. 调用附件上传 API;4. 展示文件名、版本、大小和状态;5. 上传失败展示错误 | +| 验收标准 | 上传即存储;前端展示当前对话附件;切换对话不串附件 | +| 验证命令 | Playwright 上传测试 | +| Codex 执行提示 | 请实现右侧上传区前端交互,支持拖拽和选择多个文件,调用附件上传接口并展示当前对话附件列表。 | + +### FS-P6-003 实现工作流卡片与 SSE 更新 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 前端 / 工作流 | +| 前置任务 | FS-P6-002 | +| 涉及文件 | `static/js/app.js`、实际静态 CSS 文件 | +| 目标 | 在发送提示词触发工作流后创建卡片,并根据 SSE 更新节点状态 | +| 开发步骤 | 1. 解析 chat stream 中的 workflow meta;2. 创建 workflow card;3. 连接 batch events SSE;4. 更新节点 pending/running/retrying/success/failed/skipped;5. workflow_completed 后更新完成状态;6. 页面刷新后通过状态接口恢复 | +| 验收标准 | 工作流节点实时更新;刷新页面可恢复;失败状态可见 | +| 验证命令 | Playwright 工作流卡片测试 | +| Codex 执行提示 | 请实现工作流卡片前端逻辑,接收 workflow meta 后连接事件流,实时更新上传、解压、扫描、解析、识别、输出、完成等节点状态。 | + +### FS-P6-004 实现 Markdown 安全渲染 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 前端 / 渲染 | +| 前置任务 | FS-P6-003 | +| 涉及文件 | `templates/home.html`、`static/js/app.js`、静态依赖文件或 CDN 引入 | +| 目标 | 让助手消息支持 Markdown 表格和下载链接渲染 | +| 开发步骤 | 1. 引入 `marked + DOMPurify`;2. 普通用户消息保持 escape;3. 助手消息使用安全 Markdown 渲染;4. 历史消息渲染兼容;5. 下载链接可点击 | +| 验收标准 | Markdown 表格渲染为 HTML table;链接渲染为 a 标签;无明显 XSS 风险 | +| 验证命令 | Playwright Markdown 渲染测试 | +| Codex 执行提示 | 请引入 marked + DOMPurify 实现助手消息安全 Markdown 渲染,确保文件汇总结果表格和下载链接正常显示。 | + +### FS-P6-005 实现 Playwright 端到端测试 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 前端 / E2E | +| 前置任务 | FS-P6-004 | +| 涉及文件 | Playwright 测试文件、测试配置 | +| 目标 | 使用真实浏览器覆盖上传、触发、卡片、渲染、下载和恢复 | +| 开发步骤 | 1. 创建测试用户;2. 登录系统;3. 打开审核智能体页面;4. 上传动态生成的测试文件;5. 发送“自动汇总文件目录与页数”;6. 等待工作流卡片完成;7. 验证 Markdown table 和下载链接;8. 刷新后验证卡片恢复;9. 验证越权访问失败 | +| 验收标准 | Playwright 端到端测试通过;关键页面截图可生成;失败时能定位到具体断言 | +| 验证命令 | Playwright 对应执行命令 | +| Codex 执行提示 | 请使用 Playwright 增加真实浏览器端到端测试,从登录、上传、发送提示词一直验证到报告渲染、下载和刷新恢复。 | + +### P6 阶段提交规则 + +| 项目 | 内容 | +| --- | --- | +| 阶段验证 | Playwright 端到端测试 + 相关后端接口测试 | +| 提交动作 | 调用 `git-commit-summary` 生成提交摘要并提交 | +| Codex 执行提示 | P6 验证通过后,请调用 `git-commit-summary` 分析本阶段变更,并按其输出提交到当前开发分支。 | + +--- + +## 十二、P7 测试、部署与总体验收 + +### FS-P7-001 补齐后端测试矩阵 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 测试 / 后端 | +| 前置任务 | P6 | +| 涉及文件 | `tests/test_file_summary_*.py` | +| 目标 | 覆盖单元、接口、工作流集成和权限隔离 | +| 开发步骤 | 1. 覆盖触发词;2. 覆盖附件版本;3. 覆盖解压安全;4. 覆盖文件扫描;5. 覆盖页数统计;6. 覆盖报告导出;7. 覆盖下载权限;8. 覆盖完整工作流 | +| 验收标准 | 后端文件汇总测试全部通过;失败场景覆盖充分 | +| 验证命令 | `pytest tests/test_file_summary_*.py` | +| Codex 执行提示 | 请补齐文件汇总后端测试矩阵,覆盖单元、接口、工作流集成和权限隔离。 | + +### FS-P7-002 补充部署与 Docker 说明 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 部署 / 文档 | +| 前置任务 | FS-P7-001 | +| 涉及文件 | README 或部署说明文档 | +| 目标 | 写明生产或 Docker 部署时的依赖安装和验证方式 | +| 开发步骤 | 1. 写明 Python 依赖安装;2. 写明 7z/p7zip 安装;3. 写明 rar/7z 验证命令;4. 写明 LibreOffice 非必需、仅未来增强使用;5. 写明 media 文件存储目录 | +| 验收标准 | 部署说明可指导在 Docker 中启用 rar/7z 解压;依赖边界清楚 | +| 验证命令 | `python manage.py check` | +| Codex 执行提示 | 请补充部署说明,明确 Docker 环境需要安装 7z/p7zip 支持 rar/7z,LibreOffice 不是必需依赖。 | + +### FS-P7-003 执行总体验收 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 验收 / 流水线 | +| 前置任务 | FS-P7-002 | +| 涉及文件 | 无固定文件 | +| 目标 | 运行全部测试和端到端验证,确认功能完整 | +| 开发步骤 | 1. 运行 Django check;2. 运行全量 pytest;3. 运行 Playwright E2E;4. 手工或自动验证下载文件可打开;5. 检查数据库记录;6. 检查 git status | +| 验收标准 | 总体验收标准全部满足;没有未解释的失败测试;没有意外文件变更 | +| 验证命令 | `python manage.py check`; `pytest`; Playwright 对应命令 | +| Codex 执行提示 | 请执行文件汇总功能总体验收,运行后端全量测试和 Playwright 端到端测试,确认所有验收标准已满足。 | + +### FS-P7-004 合并回 V2 分支 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | Git / 收尾 | +| 前置任务 | FS-P7-003 | +| 涉及文件 | 无固定文件 | +| 目标 | 将开发分支合并回 `V2`,并在合并后再次运行总体验收 | +| 开发步骤 | 1. P7 通过后调用 `git-commit-summary` 提交阶段变更;2. 切换到 `V2`;3. 合并开发分支;4. 解决冲突但不得覆盖用户变更;5. 合并后运行总体验收;6. 保留最终 git status | +| 验收标准 | 开发分支成功合并到 `V2`;合并后测试通过;本地 Git 历史包含阶段提交 | +| 验证命令 | `git branch --show-current`; `git status --short`; `python manage.py check`; `pytest`; Playwright 对应命令 | +| Codex 执行提示 | 请在全部阶段完成后提交 P7 变更,切回 `V2` 并合并开发分支,合并后重新运行总体验收。 | + +### P7 阶段提交规则 + +| 项目 | 内容 | +| --- | --- | +| 阶段验证 | `python manage.py check`; `pytest`; Playwright 端到端测试 | +| 提交动作 | 调用 `git-commit-summary` 生成提交摘要并提交 | +| 合并动作 | 所有阶段提交完成后合并回 `V2` | +| Codex 执行提示 | P7 验证通过后,请调用 `git-commit-summary` 提交本阶段变更,然后合并回 `V2` 并再次总体验收。 | + +--- + +## 十三、测试分层要求 + +| 层级 | 验证内容 | 建议文件 | +| --- | --- | --- | +| 单元测试 | 触发词、附件版本、解压安全、文件扫描、页数统计、报告生成 | `tests/test_file_summary_*.py` | +| 接口测试 | 上传接口、批次状态接口、事件接口、下载接口、权限隔离 | `tests/test_file_summary_views.py` | +| 工作流集成测试 | 上传附件后发送提示词,完整执行到生成 Markdown/Excel | `tests/test_file_summary_workflow.py` | +| Playwright E2E | 登录、上传、触发、卡片更新、Markdown 渲染、下载、刷新恢复 | Playwright 测试文件 | +| 部署验证 | requirements 安装成功,Docker 中 7z/p7zip 可用,rar/7z 解压可跑通 | 部署说明和验证命令 | + +说明:测试样例文件不单独拆任务,可在测试代码中动态生成临时 pdf、docx、xlsx、pptx、zip、7z、rar、损坏文件或不可读文件。 + +--- + +## 十四、Codex 自动化执行规则 + +| 规则 | 内容 | +| --- | --- | +| 顺序执行 | 必须从 P0 到 P7 顺序执行,不得跳阶段 | +| 当前阶段优先 | 某阶段测试失败时,必须先修复当前阶段,不得继续后续阶段 | +| 连续失败处理 | 同一阶段连续 3 次失败时,记录阻塞原因、已尝试方案和下一步建议 | +| 每任务验证 | 每个任务完成后运行对应验证命令或说明无法运行原因 | +| 每阶段提交 | 每个阶段全部任务完成并验证通过后,调用 `git-commit-summary` 后本地提交 | +| 前端强验证 | P6 完成后必须运行 Playwright 端到端测试和截图/断言验证 | +| 不覆盖变更 | 不得回滚或覆盖用户已有未提交变更 | +| 合并收尾 | 全部完成后必须合并回 `V2` 并再次总体验收 | + +--- + +## 十五、推荐一键执行提示词 + +后续可直接对 Codex 输入: + +```text +请按 docs/5.开发计划/1.自动汇总.md 执行,从 V2 创建 codex/YYYYMMDD-自动汇总文件目录页数 分支,按 P0 到 P7 顺序开发、验证和阶段提交。每个阶段完成后调用 git-commit-summary 生成提交摘要并本地提交。全部完成后合并回 V2,并重新运行总体验收。 +``` + +--- + +## 十六、待执行前检查清单 + +| 检查项 | 状态 | +| --- | --- | +| 需求分析、功能设计、详细设计、数据库设计均已存在 | 待执行时确认 | +| 当前分支是否为 `V2` | 待执行时确认 | +| 是否存在用户未提交变更 | 待执行时确认 | +| Python 依赖是否可安装 | 待执行时确认 | +| Playwright 或对应 MCP/Skill 是否可用 | 待执行时确认 | +| 执行机器是否提供 `git-commit-summary` skill | 待执行时确认 | +| Docker 环境是否可安装 7z/p7zip | 待执行时确认 | diff --git a/docs/5.开发计划/2.NMPA注册资料法规核查与整改闭环-第一批主链路.md b/docs/5.开发计划/2.NMPA注册资料法规核查与整改闭环-第一批主链路.md new file mode 100644 index 0000000..ef8dddf --- /dev/null +++ b/docs/5.开发计划/2.NMPA注册资料法规核查与整改闭环-第一批主链路.md @@ -0,0 +1,415 @@ +# NMPA 注册资料法规核查与整改闭环开发计划(第一批:主链路) + +## 一、已确认口径 + +| 问题 | 结论 | +| --- | --- | +| 第二阶段覆盖范围 | 覆盖原始需求 2、4、5:法规完整性核查、章节/一致性核查、风险预警与整改建议 | +| 原始需求 3 | 本阶段只做核查所需的信息抽取,不做自动填写目标文件 | +| 执行策略 | 第二阶段拆成两次 Codex 目标执行;第一批先打通 Demo 主链路 | +| 启动方式 | 用户对话提示词触发法规核查工作流,不做上传后自动核查 | +| 汇总批次 | 默认复用当前对话最近一次成功 `FileSummaryBatch`,不自动串联文件汇总 | +| 规则来源 | Demo 先用本地 YAML;数据库记录规则版本、路径、hash、RAG 索引信息 | +| 规则差异 | 自动检测 YAML 与数据库记录差异,提示人工确认更新;第一批不做规则管理前端 | +| RAG | 必须使用向量库;默认 ChromaDB | +| Embedding | Provider 可配置;Demo 默认 SiliconFlow `Qwen/Qwen3-Embedding-4B` | +| 法规材料 | 先索引 `docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告` | +| 法规文档抽取 | 允许使用 LibreOffice headless 转换本地法规 `.doc` 材料;该依赖只服务 RAG 建库,不改变第一阶段页数统计口径 | +| ChromaDB 运行方式 | 第一批采用本地持久化模式,不单独启动 Chroma Server | +| 飞书 | 第一批不接真实飞书;暂缓项写入待办计划 | + +--- + +## 二、第一批目标 + +第一批只追求“可运行、可演示、可追溯”的法规核查主链路: + +```text +已有文件汇总批次 +-> 用户提示词触发法规核查 +-> 读取本地 YAML 规则 +-> 检查规则版本和 RAG 索引状态 +-> 使用 ChromaDB 检索法规依据 +-> 完整性核查 +-> 基础章节核查 +-> 基础一致性核查 +-> 风险分级和整改建议 +-> 生成对话摘要、Markdown 报告、Excel 清单、JSON 结果包 +-> 前端展示法规核查工作流卡片 +``` + +第一批完成后,Demo 应能展示: + +| 展示项 | 内容 | +| --- | --- | +| 法规依据 | RAG 返回本地法规材料来源和片段 | +| 完整性问题 | 如缺少注册检验报告、临床评价资料等 | +| 章节问题 | 如说明书缺少储存条件、有效期、样本要求等章节 | +| 一致性问题 | 如产品名称、型号规格、预期用途在不同文件中不一致 | +| 风险清单 | blocking/high/medium/low/info 五级 | +| 报告下载 | Markdown、Excel、JSON | + +--- + +## 三、阶段拆分 + +| 阶段 | 名称 | 目标 | 验收 | +| --- | --- | --- | --- | +| RR1-0 | 准备与回归 | 确认第一阶段稳定,创建开发分支 | `pytest` 通过 | +| RR1-1 | 模型与兼容改造 | 新增法规核查模型,兼容工作流/导出通用字段 | migration 和模型测试通过 | +| RR1-2 | YAML 规则与版本记录 | 建立 Demo 规则文件、规则版本表、hash 差异检测 | 能识别 YAML 与 DB 差异 | +| RR1-3 | RAG 索引与检索 | 用 ChromaDB + SiliconFlow embedding 构建本地法规索引 | 能检索法规依据 | +| RR1-4 | 触发与工作流骨架 | 对话提示词触发法规核查,复用最近成功汇总批次 | 能创建并运行法规核查批次 | +| RR1-5 | 核查服务 | 完整性、基础章节、基础一致性核查 | 生成 findings | +| RR1-6 | 风险与导出 | 风险归并、Issue 落库、报告导出 | 生成助手摘要和下载文件 | +| RR1-7 | 前端与验收 | 法规核查卡片、状态恢复、Markdown 结果展示 | 全量测试通过 | + +--- + +## 四、RR1-0 准备与回归 + +### 任务 + +| 编号 | 内容 | +| --- | --- | +| RR1-0-001 | 从当前稳定分支创建 `codex/YYYYMMDD-NMPA法规核查主链路` | +| RR1-0-002 | 运行 `python manage.py check`、`pytest` | +| RR1-0-003 | 记录第一阶段边界:文件夹上传不作为强验收、RAR 依赖 7z、Office 页数口径可不精确 | + +### 验证命令 + +```bash +python manage.py check +pytest +git status --short +``` + +### Codex 执行提示 + +```text +请创建第二阶段第一批开发分支,先确认第一阶段文件汇总功能全量测试通过。本阶段不要修改业务代码,只做环境和边界确认。 +``` + +--- + +## 五、RR1-1 模型与兼容改造 + +### 任务 + +| 编号 | 内容 | 文件 | +| --- | --- | --- | +| RR1-1-001 | 新增法规核查模型和枚举 | `review_agent/models.py` | +| RR1-1-002 | 给 `WorkflowNodeRun` 增加 `workflow_type`、`workflow_batch_id`、`node_group` | `review_agent/models.py` | +| RR1-1-003 | 给 `WorkflowEvent` 增加 `workflow_type`、`workflow_batch_id`、`conversation_id` | `review_agent/models.py` | +| RR1-1-004 | 给 `ExportedSummaryFile` 增加 `workflow_type`、`workflow_batch_id`、`export_category` | `review_agent/models.py` | +| RR1-1-005 | 保持第一阶段文件汇总写入兼容 | `review_agent/file_summary/*` | +| RR1-1-006 | 生成 migration 并补模型测试 | `review_agent/migrations/`、`tests/test_regulatory_models.py` | + +### 新增模型 + +| 模型 | 说明 | +| --- | --- | +| `RegulatoryRuleVersion` | 规则版本、YAML 路径、文件 hash、RAG 索引版本 | +| `RegulatoryReviewBatch` | 法规核查批次 | +| `RegulatoryIssue` | 风险问题和整改状态 | +| `RegulatoryArtifact` | 过程产物 | +| `RegulatoryNotificationRecord` | mock 通知预留记录,第一批可只建表不接真实通知 | + +### 验证命令 + +```bash +python manage.py makemigrations review_agent +python manage.py migrate +python manage.py check +pytest tests/test_regulatory_models.py tests/test_file_summary_workflow.py tests/test_file_summary_views.py +``` + +### Codex 执行提示 + +```text +请新增法规核查相关模型,并轻量通用化现有工作流节点、事件和导出文件表。必须保持第一阶段文件汇总测试通过,不要重写第一阶段工作流。 +``` + +--- + +## 六、RR1-2 YAML 规则与版本记录 + +### 任务 + +| 编号 | 内容 | 文件 | +| --- | --- | --- | +| RR1-2-001 | 新建法规核查模块目录 | `review_agent/regulatory_review/` | +| RR1-2-002 | 编写 Demo YAML 规则 | `review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml` | +| RR1-2-003 | 实现规则 hash 计算和版本记录 | `services/rule_loader.py` | +| RR1-2-004 | 实现 YAML 与 DB 差异检测 | `services/rule_loader.py` | +| RR1-2-005 | 增加规则版本初始化/检查管理命令 | `management/commands/regulatory_rules_check.py` | +| RR1-2-006 | 增加测试 | `tests/test_regulatory_rule_loader.py` | + +### Demo 规则至少覆盖 + +| 文件项 | 类型 | 风险 | +| --- | --- | --- | +| 产品技术要求 | required | blocking | +| 说明书 | required | high | +| 注册检验报告 | required | blocking | +| 临床评价资料 | conditional | high | +| 安全和性能基本原则清单 | recommended | medium | + +YAML 规则内容需参考本地法规资料目录: + +```text +docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告 +``` + +### 验证命令 + +```bash +pytest tests/test_regulatory_rule_loader.py +python manage.py regulatory_rules_check +``` + +### Codex 执行提示 + +```text +请建立 Demo 版 NMPA IVD 注册资料 YAML 规则库,并实现规则版本、文件 hash 和数据库记录差异检测。发现 YAML 与 DB hash 不一致时只提示需要更新,不自动覆盖。 +``` + +--- + +## 七、RR1-3 RAG 索引与检索 + +### 任务 + +| 编号 | 内容 | 文件 | +| --- | --- | --- | +| RR1-3-001 | 增加依赖 `chromadb` 和必要 HTTP 客户端 | `requirements.txt` | +| RR1-3-002 | 实现 embedding provider 抽象 | `services/rag_embedding.py` | +| RR1-3-003 | 实现 SiliconFlow embedding provider | `services/rag_embedding.py` | +| RR1-3-004 | 实现法规文档文本抽取和切块 | `services/rag_index.py` | +| RR1-3-005 | 实现 ChromaDB 持久化索引构建命令 | `management/commands/regulatory_rag_build.py` | +| RR1-3-006 | 实现 RAG 引用检索服务 | `services/rag_citation.py` | +| RR1-3-007 | 增加测试 | `tests/test_regulatory_rag.py` | + +### 配置 + +| 配置项 | 默认 | +| --- | --- | +| `REGULATORY_RAG_PROVIDER` | `siliconflow` | +| `REGULATORY_RAG_CHROMA_PATH` | `media/regulatory_review/rag/chroma/` | +| `SILICONFLOW_BASE_URL` | `https://api.siliconflow.cn/v1` | +| `SILICONFLOW_API_KEY` | 从环境变量读取 | +| `SILICONFLOW_EMBEDDING_MODEL` | `Qwen/Qwen3-Embedding-4B` | +| `SILICONFLOW_EMBEDDING_DIMENSIONS` | `1024` | +| `REGULATORY_RAG_COLLECTION` | `nmpa_ivd_registration_v1` | + +SiliconFlow Embedding API 参考: + +```text +https://docs.siliconflow.com/en/api-reference/embeddings/create-embeddings +``` + +### 规则 + +| 场景 | 处理 | +| --- | --- | +| RAG 索引不存在 | 核查时提示先构建索引,不在核查中临时构建 | +| Embedding API 不可用 | 构建命令失败,核查不启动 | +| RAG 无命中 | 规则问题仍输出,法规依据标记“原文依据待补充” | +| 本地法规 `.doc` 无法直接抽取 | 允许通过 LibreOffice headless 转换后抽取;Docker 部署说明需写明可选安装方式 | +| ChromaDB 存储 | 使用本地持久化目录,Docker 部署时通过 volume 挂载保留索引 | + +### 验证命令 + +```bash +python manage.py regulatory_rag_build +pytest tests/test_regulatory_rag.py +``` + +### Codex 执行提示 + +```text +请实现基于 ChromaDB 的本地法规 RAG。Embedding Provider 要可配置,Demo 默认使用 SiliconFlow Qwen/Qwen3-Embedding-4B。ChromaDB 使用本地持久化目录,不单独启动服务。法规 `.doc` 材料允许用 LibreOffice headless 转换后抽取。核查流程只检查索引可用性,不临时构建索引。 +``` + +--- + +## 八、RR1-4 触发与工作流骨架 + +### 任务 + +| 编号 | 内容 | 文件 | +| --- | --- | --- | +| RR1-4-001 | 实现法规核查提示词路由 | `review_agent/skill_router.py` | +| RR1-4-002 | 实现法规核查批次创建 | `regulatory_review/workflow.py` | +| RR1-4-003 | 默认查找当前对话最近成功 `FileSummaryBatch` | `workflow.py` | +| RR1-4-004 | 无成功汇总批次时提示用户先执行自动汇总 | `services.py` | +| RR1-4-005 | 实现启动、状态、事件接口 | `regulatory_review/views.py`、`urls.py` | +| RR1-4-006 | 接入项目 URL | `config/urls.py` 或 `review_agent/urls.py` | +| RR1-4-007 | 增加测试 | `tests/test_regulatory_workflow.py`、`tests/test_regulatory_views.py` | + +### 第一批节点 + +```text +prepare +-> rule_scope +-> completeness_check +-> text_extract +-> structure_check +-> consistency_check +-> risk_assess +-> report_export +-> completed +``` + +### 验证命令 + +```bash +pytest tests/test_regulatory_workflow.py tests/test_regulatory_views.py +pytest tests/test_file_summary_trigger.py tests/test_llm_streaming.py +``` + +### Codex 执行提示 + +```text +请实现法规核查提示词触发和工作流骨架。用户说“法规核查、NMPA核查、完整性核查、风险预警”等意图时启动 regulatory_review;默认复用当前对话最近成功 FileSummaryBatch;没有成功汇总批次时提示先自动汇总。 +``` + +--- + +## 九、RR1-5 核查服务 + +### 任务 + +| 编号 | 内容 | 文件 | +| --- | --- | --- | +| RR1-5-001 | 实现统一 Finding dataclass | `regulatory_review/schemas.py` | +| RR1-5-002 | 完整性核查:文件名、目录名、首页文本匹配 | `services/completeness_check.py` | +| RR1-5-003 | 文本抽取:docx/pdf/xlsx/pptx/txt/md 基础文本 | `services/text_extract.py` | +| RR1-5-004 | 基础章节核查:按规则关键词判断章节是否存在 | `services/structure_check.py` | +| RR1-5-005 | 基础一致性核查:产品名称、型号规格、预期用途 | `services/consistency_check.py` | +| RR1-5-006 | 过程产物保存和 hash | `storage.py` | +| RR1-5-007 | 增加测试 | `tests/test_regulatory_completeness.py`、`tests/test_regulatory_text_extract.py`、`tests/test_regulatory_structure.py`、`tests/test_regulatory_consistency.py` | + +### Demo 验收样例 + +测试或演示资料中至少构造: + +| 条件 | 预期 | +| --- | --- | +| 有说明书 | 可匹配说明书规则 | +| 有产品技术要求 | 可匹配产品技术要求规则 | +| 缺少注册检验报告 | 生成 blocking 问题 | +| 说明书缺少储存条件章节 | 生成 high 或 medium 问题 | +| 产品名称在两个文件中不一致 | 生成 consistency 问题 | + +### 验证命令 + +```bash +pytest tests/test_regulatory_completeness.py tests/test_regulatory_text_extract.py tests/test_regulatory_structure.py tests/test_regulatory_consistency.py +``` + +### Codex 执行提示 + +```text +请实现完整性核查、文本抽取、基础章节核查和基础一致性核查。所有核查服务只返回 Finding,不直接创建 RegulatoryIssue。 +``` + +--- + +## 十、RR1-6 风险与导出 + +### 任务 + +| 编号 | 内容 | 文件 | +| --- | --- | --- | +| RR1-6-001 | Findings 去重和风险归并 | `services/risk_assess.py` | +| RR1-6-002 | RAG 引用挂载到问题证据 | `services/risk_assess.py`、`services/rag_citation.py` | +| RR1-6-003 | 创建 `RegulatoryIssue` | `services/risk_assess.py` | +| RR1-6-004 | 生成 Markdown 核查报告 | `services/export.py` | +| RR1-6-005 | 生成 Excel 缺失清单 | `services/export.py` | +| RR1-6-006 | 生成 JSON 结果包 | `services/export.py` | +| RR1-6-007 | 工作流完成后写入助手消息 | `workflow.py` | +| RR1-6-008 | 增加测试 | `tests/test_regulatory_risk_assess.py`、`tests/test_regulatory_export.py` | + +### 对话摘要 + +助手消息至少包含: + +```markdown +已完成 NMPA 注册资料法规核查。 + +| 风险等级 | 数量 | +| --- | --- | +| 阻断项 | 1 | +| 高风险 | 1 | + +| 等级 | 问题 | 状态 | 建议 | +| --- | --- | --- | --- | +| 阻断项 | 缺少注册检验报告 | 待处理 | 请补充注册检验报告并复核 | + +[下载 Markdown 核查报告](...) +[下载 Excel 缺失清单](...) +[下载 JSON 结果包](...) +``` + +### 验证命令 + +```bash +pytest tests/test_regulatory_risk_assess.py tests/test_regulatory_export.py tests/test_regulatory_workflow.py +``` + +### Codex 执行提示 + +```text +请实现风险归并、RAG 法规依据挂载、Issue 落库和最终报告导出。工作流完成后必须向当前对话写入 Markdown 摘要和下载链接。 +``` + +--- + +## 十一、RR1-7 前端与总体验收 + +### 任务 + +| 编号 | 内容 | 文件 | +| --- | --- | --- | +| RR1-7-001 | 工作流卡片支持 `regulatory_review` 类型 | `templates/home.html`、`static/js/app.js` | +| RR1-7-002 | 卡片使用 `workflow_type + workflow_batch_id` 区分 | `static/js/app.js` | +| RR1-7-003 | 显示法规核查节点和风险摘要 | `templates/home.html`、`static/js/app.js` | +| RR1-7-004 | 页面刷新恢复法规核查卡片 | `views.py`、`static/js/app.js` | +| RR1-7-005 | 补前端测试 | `tests/test_regulatory_frontend.py` | +| RR1-7-006 | 全量回归 | 全项目 | + +### 验证命令 + +```bash +python manage.py check +pytest +``` + +如浏览器可用,再运行 Playwright 端到端验证。 + +### Codex 执行提示 + +```text +请在现有工作流卡片轮播基础上支持 regulatory_review 类型,展示法规核查节点、风险摘要和完成状态。最后运行 python manage.py check 和 pytest 全量验收。 +``` + +--- + +## 十二、第一批 Codex 目标模式提示词 + +```text +请按 docs/5.开发计划/2.NMPA注册资料法规核查与整改闭环-第一批主链路.md 执行第二阶段第一批开发。 + +目标: +完成 NMPA 法规核查主链路,复用当前对话最近成功 FileSummaryBatch,通过用户提示词触发 regulatory_review 工作流,实现 YAML 规则、ChromaDB + SiliconFlow Embedding RAG、完整性核查、基础章节核查、基础一致性核查、风险分级、Markdown/Excel/JSON 报告和前端法规核查卡片。 + +执行规则: +1. 创建 codex/YYYYMMDD-NMPA法规核查主链路 分支。 +2. 按 RR1-0 到 RR1-7 顺序执行,不跳阶段。 +3. 每阶段完成后运行对应验证命令。 +4. 第一阶段文件汇总测试不得回归。 +5. 不自动串联文件汇总;没有成功汇总批次时提示用户先自动汇总。 +6. 不接真实飞书,不做规则管理前端,不做自动填写目标文件。 +7. 最后运行 python manage.py check 和 pytest 全量验收。 +``` diff --git a/docs/5.开发计划/2.NMPA注册资料法规核查与整改闭环-第二批完整闭环.md b/docs/5.开发计划/2.NMPA注册资料法规核查与整改闭环-第二批完整闭环.md new file mode 100644 index 0000000..26f2dba --- /dev/null +++ b/docs/5.开发计划/2.NMPA注册资料法规核查与整改闭环-第二批完整闭环.md @@ -0,0 +1,304 @@ +# NMPA 注册资料法规核查与整改闭环开发计划(第二批:完整闭环补齐) + +## 一、第二批目标 + +第二批在第一批主链路通过后执行,补齐完整整改闭环和交互能力: + +```text +适用条件对话选择框 +-> waiting_user 暂停恢复 +-> 附件 4 申报资料目录规则对齐 +-> 整包复核 +-> 缺失项复核 +-> mock 通知留痕 +-> 更完整的过程产物 +-> 更强的前端交互和验收测试 +``` + +飞书真实 CLI/API、规则管理前端、自动填写目标文件不在第二批落地,进入 `docs/6.待办计划/第二阶段暂缓事项.md`。 + +--- + +## 二、阶段总览 + +| 阶段 | 名称 | 目标 | 验收 | +| --- | --- | --- | --- | +| RR2-1 | 适用条件确认 | 对话选择框确认产品类别、注册类型、临床评价路径等 | waiting_user 可暂停恢复 | +| RR2-2 | 附件 4 规则对齐与核查能力增强 | 按《体外诊断试剂注册申报资料要求及说明》扩展完整目录规则、章节、一致性、RAG 引用和文本抽取范围 | 能识别附件 4 一级/二级目录缺失和关键字段问题 | +| RR2-3 | 整包复核 | 基于新的汇总批次创建新的法规核查批次 | 可追溯来源批次 | +| RR2-4 | 缺失项复核 | 针对原 Issue 执行复核并更新状态 | 生成 review_record | +| RR2-5 | mock 通知留痕 | 对 blocking/high/medium 写 mock 通知记录 | 报告展示通知记录 | +| RR2-6 | 前端和总体验收 | 条件选择框、复核入口、通知/复核记录展示 | 全量测试通过 | + +--- + +## 三、RR2-1 适用条件确认 + +### 任务 + +| 编号 | 内容 | 文件 | +| --- | --- | --- | +| RR2-1-001 | 实现适用条件候选识别 | `services/info_extract.py` | +| RR2-1-002 | 工作流支持 `waiting_user` 暂停 | `regulatory_review/workflow.py` | +| RR2-1-003 | 实现条件确认接口 | `regulatory_review/views.py` | +| RR2-1-004 | 实现对话选择框 UI | `templates/home.html`、`static/js/app.js` | +| RR2-1-005 | 确认后从 `rule_scope` 或下一节点恢复 | `workflow.py` | +| RR2-1-006 | 增加测试 | `tests/test_regulatory_condition.py`、`tests/test_regulatory_frontend.py` | + +### 确认字段 + +以下选项来自既有第二阶段功能/详细设计:`RegulatoryInfoExtract` 输出产品类别、注册类型、临床评价路径,功能设计中明确注册类型包括“首次注册、变更注册、延续注册等”,临床评价路径包括“临床试验、免临床、同品种比对等”。因此 Demo 版按下表实现。 + +| 字段 | 交互 | +| --- | --- | +| 产品类别 | 体外诊断试剂 / 医疗器械 / 其他 | +| 注册类型 | 首次注册 / 变更注册 / 延续注册 | +| 临床评价路径 | 临床试验 / 免临床 / 同品种比对 / 待确认 | +| 产品名称 | 文本输入 | +| 型号规格 | 文本输入 | +| 预期用途 | 文本输入 | + +### 验证命令 + +```bash +pytest tests/test_regulatory_condition.py tests/test_regulatory_frontend.py tests/test_regulatory_workflow.py +``` + +### Codex 执行提示 + +```text +请实现法规适用条件候选识别、waiting_user 暂停恢复和对话选择框确认。用户确认前工作流不得继续执行规则裁剪。 +``` + +--- + +## 四、RR2-2 附件 4 规则对齐与核查能力增强 + +### 新增口径:附件 4 必须结构化入规则库 + +第一批主链路已经可以演示,但现有 Demo YAML 只覆盖 5 类规则:产品技术要求、说明书、注册检验报告、临床评价资料、安全和性能基本原则清单。经人工确认,第一批链路可通过;但与附件《体外诊断试剂注册申报资料要求及说明》相比,规则覆盖仍不完整。第二批 RR2-2 必须将附件 4 的申报资料目录结构补入规则库,并作为完整性和章节核查的主要依据。 + +附件来源: + +```text +docs/0.原始材料/附件 4 体外诊断试剂注册申报资料要求及说明.doc +``` + +如附件仍为旧版 `.doc`,允许在开发阶段通过 Pandoc、LibreOffice headless、Word COM 或受控脚本转换为 `.docx`/`.txt` 中间产物;中间产物只用于规则抽取和测试夹具,不改变第一阶段文件页数统计口径。 + +### 附件 4 目录覆盖范围 + +第二批 Demo 规则至少覆盖以下一级和二级标题。规则应支持“章节目录”类目录项、资料文件项、条件适用项和推荐项的区分。 + +| 一级目录 | 二级目录/资料项 | +| --- | --- | +| 1. 监管信息 | 1.1 章节目录、1.2 申请表、1.3 术语/缩写词列表、1.4 产品列表、1.5 关联文件、1.6 申报前与监管机构的联系情况和沟通记录、1.7 符合性声明 | +| 2. 综述资料 | 2.1 章节目录、2.2 概述、2.3 产品描述、2.4 预期用途、2.5 申报产品上市历史、2.6 其他需说明的内容 | +| 3. 非临床资料 | 3.1 章节目录、3.2 产品风险管理资料、3.3 体外诊断试剂安全和性能基本原则清单、3.4 产品技术要求及检验报告、3.5 分析性能研究、3.6 稳定性研究、3.7 阳性判断值或参考区间研究、3.8 其他资料 | +| 4. 临床评价资料 | 4.1 章节目录、4.2 临床评价资料 | +| 5. 产品说明书和标签样稿 | 5.1 章节目录、5.2 产品说明书、5.3 标签样稿、5.4 其他资料 | +| 6. 质量管理体系文件 | 6.1 综述、6.2 章节目录、6.3 生产制造信息、6.4 质量管理体系程序、6.5 管理职责程序、6.6 资源管理程序、6.7 产品实现程序、6.8 质量管理体系的测量/分析和改进程序、6.9 其他质量体系程序信息、6.10 质量管理体系核查文件 | + +### 规则分级默认值 + +| 规则类型 | 默认风险 | 说明 | +| --- | --- | --- | +| 一级目录整体缺失 | high | 如缺少“监管信息”“综述资料”“非临床资料”等完整章节 | +| 关键法定资料缺失 | blocking | 申请表、符合性声明、产品技术要求及检验报告等 | +| 关键技术/评价资料缺失 | high | 产品风险管理资料、分析性能研究、稳定性研究、临床评价资料、产品说明书、标签样稿等 | +| 条件适用资料缺失 | medium/high | 如上市历史、申报前沟通记录、其他资料;需结合 RR2-1 适用条件判断 | +| 章节目录缺失 | medium | 各一级目录下的章节目录缺失,影响资料可追溯性 | + +### 与现有第一批链路的差异修正 + +| 当前能力 | 第二批修正 | +| --- | --- | +| 完整性核查只按文件名和相对路径匹配 | 增加目录名、首页文本/前若干页文本、章节标题候选匹配 | +| YAML 只覆盖 5 个 Demo 条目 | 扩展为附件 4 一级/二级目录规则,保留第一批 5 条并映射到附件 4 对应章节 | +| 章节核查只检查说明书储存条件/有效期/样本要求 | 改为同时检查申报资料目录结构和说明书内部关键章节 | +| RAG 可能跳过 `.doc` 材料 | 附件 4 必须可被转换或抽取,构建 RAG 前输出可读文本抽取状态 | +| 一致性只检查产品名称、型号规格、预期用途 | 保留这三项,并增加管理类别、分类编码、注册类型、临床评价路径等候选字段 | + +### 任务 + +| 编号 | 内容 | 文件 | +| --- | --- | --- | +| RR2-2-001 | 将附件 4 `.doc` 抽取为可测试的结构化目录夹具 | `tests/fixtures/regulatory/attachment4_outline.json` 或同等 fixture | +| RR2-2-002 | 扩展 YAML 规则,覆盖附件 4 一级/二级目录、别名、适用条件、风险等级和整改建议 | `rules/nmpa_ivd_registration_v1.yaml` | +| RR2-2-003 | 增强规则加载校验,确保附件 4 必填目录项都有规则 ID、关键词、风险等级和 citation_query | `services/rule_loader.py` | +| RR2-2-004 | 增强完整性核查,支持文件名、目录名、首页文本/前若干页文本、章节标题候选匹配 | `services/completeness_check.py`、`services/text_extract.py` | +| RR2-2-005 | 增强文本抽取,缓存章节候选、字段候选、首页文本和抽取状态 | `services/text_extract.py`、`storage.py` | +| RR2-2-006 | 增强章节核查,支持附件 4 目录层级、别名、近似标题和证据片段 | `services/structure_check.py` | +| RR2-2-007 | 增强一致性核查,支持产品名称、型号规格、预期用途、管理类别、分类编码、注册类型、临床评价路径等来源值 | `services/consistency_check.py` | +| RR2-2-008 | RAG 引用写入 `rag_result_json` 过程产物,并记录附件 4 文本抽取/索引状态 | `services/rag_citation.py`、`storage.py` | +| RR2-2-009 | 增加附件 4 对齐测试 | `tests/test_regulatory_rule_loader.py`、`tests/test_regulatory_completeness.py`、`tests/test_regulatory_structure.py`、`tests/test_regulatory_consistency.py`、`tests/test_regulatory_rag.py` | + +### 验收样例 + +| 样例条件 | 预期 | +| --- | --- | +| 文件包缺少“监管信息/申请表” | 生成 blocking 或 high 问题,并引用附件 4 监管信息要求 | +| 文件包缺少“产品风险管理资料” | 生成 high 问题,category 为 completeness | +| 文件包缺少“分析性能研究”或“稳定性研究” | 生成 high 问题,给出补充研究资料建议 | +| 文件包有产品技术要求但无检验报告 | 生成 blocking 问题,规则映射到 3.4 | +| 文件包有产品说明书但无标签样稿 | 生成 high 问题,规则映射到 5.3 | +| 文件包缺少质量管理体系文件 | 生成 high 问题,规则映射到第 6 章 | +| 附件 4 `.doc` 未能抽取 | RAG 构建命令失败或明确报告附件 4 抽取失败,不允许静默跳过该核心材料 | + +### 验证命令 + +```bash +pytest tests/test_regulatory_rule_loader.py tests/test_regulatory_completeness.py tests/test_regulatory_structure.py tests/test_regulatory_consistency.py tests/test_regulatory_rag.py +``` + +### Codex 执行提示 + +```text +请先将附件 4《体外诊断试剂注册申报资料要求及说明》结构化为规则覆盖清单,再增强 YAML、完整性核查、章节核查、一致性核查和 RAG 过程产物。第二批必须覆盖附件 4 的 1-6 章一级目录和主要二级目录;证据必须包含文件路径、命中片段、字段名或规则 ID,便于人工复核。附件 4 作为核心法规材料,不允许在 RAG 构建中静默跳过。 +``` + +--- + +## 五、RR2-3 整包复核 + +### 口径 + +整包复核不是修改原法规核查批次,而是基于新的成功 `FileSummaryBatch` 创建新的 `RegulatoryReviewBatch`。新批次记录来源批次信息,用于报告中展示“复核来源”。 + +复核入口不新增独立页面。前端通过法规核查工作流卡片展示复核入口,用户点击后由 AI 在对话区发起确认与引导。 + +### 任务 + +| 编号 | 内容 | 文件 | +| --- | --- | --- | +| RR2-3-001 | 新增整包复核启动接口 | `regulatory_review/views.py` | +| RR2-3-002 | 支持指定新的 `file_summary_batch_id` | `workflow.py` | +| RR2-3-003 | 记录 source/regenerated_from 信息 | `RegulatoryReviewBatch.condition_json` 或独立字段 | +| RR2-3-004 | 报告展示整包复核来源 | `services/export.py` | +| RR2-3-005 | 增加测试 | `tests/test_regulatory_rectification.py` | + +### 验证命令 + +```bash +pytest tests/test_regulatory_rectification.py tests/test_regulatory_workflow.py +``` + +### Codex 执行提示 + +```text +请实现整包复核:用户完成新的文件汇总后,可基于新 FileSummaryBatch 创建新的 RegulatoryReviewBatch,并在报告中追溯原核查批次。 +``` + +--- + +## 六、RR2-4 缺失项复核 + +### 口径 + +缺失项复核针对原 `RegulatoryIssue` 更新状态,不新建完整法规核查批次。系统可读取补充文件对应的新 `FileSummaryBatch`,只对指定问题重新匹配相关规则。 + +缺失项复核同样不新增独立页面。卡片只展示入口和状态,具体确认动作通过 AI 对话完成,例如确认复核哪些问题、使用哪个补充文件汇总批次。 + +### 任务 + +| 编号 | 内容 | 文件 | +| --- | --- | --- | +| RR2-4-001 | 实现缺失项复核服务 | `services/rectification_review.py` | +| RR2-4-002 | 支持 issue_ids + file_summary_batch_id 输入 | `views.py` | +| RR2-4-003 | 复核通过更新 `review_passed`,不通过更新 `review_failed` | `services/rectification_review.py` | +| RR2-4-004 | 生成 `review_record` 过程产物 | `storage.py` | +| RR2-4-005 | 报告展示复核记录 | `services/export.py` | +| RR2-4-006 | 增加测试 | `tests/test_regulatory_rectification.py` | + +### 验证命令 + +```bash +pytest tests/test_regulatory_rectification.py +``` + +### Codex 执行提示 + +```text +请实现缺失项复核。复核不重新跑完整法规核查工作流,只针对指定 RegulatoryIssue 和补充文件汇总批次更新问题状态,并生成 review_record 产物。 +``` + +--- + +## 七、RR2-5 mock 通知留痕 + +### 口径 + +真实飞书暂缓。第二批只在 blocking/high/medium 风险项出现时创建 `RegulatoryNotificationRecord(channel=mock)`,用于报告留痕和第三阶段接入。 + +### 任务 + +| 编号 | 内容 | 文件 | +| --- | --- | --- | +| RR2-5-001 | 实现 mock notifier | `services/feishu_notifier.py` | +| RR2-5-002 | 风险等级 blocking/high/medium 写通知记录 | `workflow.py` | +| RR2-5-003 | 通知记录进入 Markdown/Excel/JSON 报告 | `services/export.py` | +| RR2-5-004 | 增加测试 | `tests/test_regulatory_notification.py` | + +### 验证命令 + +```bash +pytest tests/test_regulatory_notification.py tests/test_regulatory_export.py +``` + +### Codex 执行提示 + +```text +请实现 mock 通知留痕。不要接真实飞书 CLI/API;只为阻断项、高风险、中风险写 RegulatoryNotificationRecord,并在报告中展示。 +``` + +--- + +## 八、RR2-6 前端和总体验收 + +### 任务 + +| 编号 | 内容 | 文件 | +| --- | --- | --- | +| RR2-6-001 | 前端显示条件确认卡片 | `templates/home.html`、`static/js/app.js` | +| RR2-6-002 | 前端通过工作流卡片展示整包复核入口,并由 AI 对话确认 | `static/js/app.js` | +| RR2-6-003 | 前端通过工作流卡片展示缺失项复核入口,并由 AI 对话确认 | `static/js/app.js` | +| RR2-6-004 | 卡片展示通知和复核摘要 | `templates/home.html`、`static/js/app.js` | +| RR2-6-005 | 补 Playwright 或前端测试 | `tests/test_regulatory_frontend.py` | +| RR2-6-006 | 全量回归 | 全项目 | + +### 验证命令 + +```bash +python manage.py check +pytest +``` + +### Codex 执行提示 + +```text +请完善法规核查前端交互,包含条件选择框、卡片式整包复核入口、卡片式缺失项复核入口、AI 对话确认、mock 通知和复核记录展示。不要新增独立复核页面。最后运行 python manage.py check 和 pytest 全量验收。 +``` + +--- + +## 九、第二批 Codex 目标模式提示词 + +```text +请按 docs/5.开发计划/2.NMPA注册资料法规核查与整改闭环-第二批完整闭环.md 执行第二阶段第二批开发。 + +前提: +第一批主链路已经完成并通过全量测试。 + +目标: +补齐法规核查完整整改闭环,包括适用条件对话选择框、waiting_user 暂停恢复、附件 4 申报资料目录规则对齐、整包复核、缺失项复核、mock 通知留痕、增强章节/一致性核查和前端交互。 + +执行规则: +1. 从第一批完成后的稳定分支创建 codex/YYYYMMDD-NMPA法规核查完整闭环 分支。 +2. 按 RR2-1 到 RR2-6 顺序执行。 +3. 每阶段完成后运行对应验证命令。 +4. RR2-2 必须覆盖附件 4 的 1-6 章一级目录和主要二级目录,不能只保留第一批 5 条 Demo 规则。 +5. 不接真实飞书 CLI/API。 +6. 不做规则管理前端。 +7. 不做自动填写目标文件。 +8. 最后运行 python manage.py check 和 pytest 全量验收。 +``` diff --git a/docs/5.开发计划/3.产品关键信息提取与申报文件自动填表.md b/docs/5.开发计划/3.产品关键信息提取与申报文件自动填表.md new file mode 100644 index 0000000..0dc6b5e --- /dev/null +++ b/docs/5.开发计划/3.产品关键信息提取与申报文件自动填表.md @@ -0,0 +1,632 @@ +# 产品关键信息提取与申报文件自动填表开发计划 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/3.产品关键信息提取与申报文件自动填表.md | +| 功能设计文档 | docs/2.功能设计/3.产品关键信息提取与申报文件自动填表.md | +| 详细设计文档 | docs/3.详细设计/3.产品关键信息提取与申报文件自动填表.md | +| 数据库设计文档 | docs/4.数据库设计/3.产品关键信息提取与申报文件自动填表.md | +| 功能名称 | 产品关键信息提取与申报文件自动填表 | +| 所属模块 | 审核智能体 review_agent | +| 执行方式 | 单人开发 + Codex 目标模式自动化执行 | +| 计划日期 | 2026-06-07 | +| 计划版本 | V1.0 | + +--- + +## 一、开发计划目标 + +本开发计划用于指导 Codex 目标模式按阶段完成“产品关键信息提取与申报文件自动填表”功能开发。该功能作为独立工作流 `application_form_fill` 实现,由用户对话触发,默认复用当前对话最近成功的文件汇总批次;如本次消息带新附件,则先串联文件汇总,再执行自动填表。 + +本期必须完成:独立填表批次、过程产物、飞书通知记录、模板配置、注册证 `.docx` 模板填充、字段抽取与合并、冲突高亮、追溯清单、Word 下载、自动填表工作流卡片和权限校验。 + +本期明确不强制完成:PDF 转换、字段级数据库表、`.doc` 模板自动转换、完整安全和性能基本原则清单条目拆解。这些事项已进入 `docs/6.待办计划/第二阶段暂缓事项.md`。 + +--- + +## 二、已确认开发规则 + +| 规则项 | 内容 | +| --- | --- | +| 工作流类型 | 新增独立 `application_form_fill`,不塞入 `regulatory_review` 工作流 | +| 触发方式 | 用户对话触发,如“帮我填注册证”“给我这个内容对应的表格”“为我该方案生成申报模板” | +| 模板指定 | 用户可指定模板;未指定时按注册类型生成适用模板 | +| 文件来源 | 无新附件时复用当前对话最近成功 `FileSummaryBatch`;有新附件时先自动汇总 | +| 模板配置 | 放在 `review_agent/application_form_fill/templates/application_form_templates_v1.yaml` | +| 字段抽取 | 规则/正则与 LLM 结构化抽取并行,合并处理 | +| 冲突处理 | 说明书优先;冲突字段在 Word 中黄色底色、红色字体 | +| 输出范围 | Demo 主链路优先 Word + Excel/JSON 追溯清单 | +| PDF | 数据结构预留,工作流节点可 skipped,不作为本期强验收 | +| 飞书 | 新增自动填表通知记录表,通知失败不阻断下载 | +| 数据库 | 新增三张表;字段级明细表暂缓 | +| Git 提交 | 每个阶段完成并验证通过后提交一次 | +| 测试要求 | 每阶段至少运行对应 pytest;前端阶段补卡片和渲染测试 | + +--- + +## 三、总体验收标准 + +| 类别 | 完成标准 | +| --- | --- | +| 数据库 | `ApplicationFormFillBatch`、`ApplicationFormFillArtifact`、`ApplicationFormFillNotificationRecord` 可通过 migration 落库 | +| 导出类型 | `ExportedSummaryFile.ExportType` 支持 `word`、`pdf`,并兼容既有 markdown/excel/json | +| 模块结构 | 新增 `review_agent/application_form_fill/` 独立模块 | +| 触发 | 用户说“帮我填注册证”等语句可触发 `application_form_fill` | +| 文件来源 | 无新附件时复用最近成功汇总批次;无汇总批次时提示上传资料 | +| 模板配置 | YAML 可加载、校验,并至少配置注册证格式 `.docx` 已识别字段 | +| 字段抽取 | 规则/正则与 LLM 抽取结果均可留底;LLM 失败时规则结果可继续 | +| 字段合并 | 说明书优先,冲突字段进入 `conflict_summary` 和追溯清单 | +| Word 填充 | 能按表格行名填入注册证模板字段,缺失字段留空 | +| 冲突高亮 | 冲突字段在 Word 内黄底红字 | +| 追溯清单 | 生成 Excel/JSON,记录规则结果、LLM 结果、合并字段、冲突和来源证据 | +| 下载 | 对话框提供填好 Word 和追溯清单下载链接 | +| 工作流卡片 | 前端支持 `application_form_fill` 卡片,展示准备资料、选择模板、复制模板、抽取字段、填写 Word 等节点 | +| 飞书通知 | 填表完成后写通知记录,可 mock;失败不阻断文件下载 | +| 权限 | A 对话不能查询或下载 B 对话的填表批次和导出文件 | +| 回归 | 第一批文件汇总、第二批法规核查既有测试不回归 | + +--- + +## 四、阶段总览 + +| 阶段 | 名称 | 目标 | 阶段验收 | +| --- | --- | --- | --- | +| AFF-0 | 准备与回归 | 创建开发分支,确认现有测试基线 | `python manage.py check` 和关键回归测试通过 | +| AFF-1 | 数据模型与通用导出扩展 | 新增三张表,扩展 word/pdf 导出类型 | migration、模型测试通过 | +| AFF-2 | 模块骨架与模板配置 | 新建独立模块、YAML 配置和配置校验 | 模板配置测试通过 | +| AFF-3 | 触发与工作流骨架 | 对话触发、批次创建、节点事件和状态查询 | 可创建并运行空工作流 | +| AFF-4 | 模板选择与文件来源 | 复用最近汇总批次,支持指定/默认模板选择 | 模板选择和来源批次测试通过 | +| AFF-5 | 字段抽取与合并 | 规则/正则 + LLM 并行抽取、冲突归并和产物留底 | 字段抽取、冲突测试通过 | +| AFF-6 | Word 填充与追溯导出 | 注册证 Word 填充、冲突高亮、Excel/JSON 追溯 | 可下载 Word 和追溯清单 | +| AFF-7 | 飞书通知与对话摘要 | 生成助手摘要、下载链接和通知记录 | 通知、摘要、下载权限测试通过 | +| AFF-8 | 前端卡片与总体验收 | 自动填表工作流卡片、状态恢复、全量回归 | 全量测试通过 | + +--- + +## 五、AFF-0 准备与回归 + +### AFF-0-001 创建开发分支并确认现状 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | Git / 准备 | +| 前置任务 | 无 | +| 涉及文件 | 无固定文件 | +| 目标 | 从当前稳定分支创建 `codex/YYYYMMDD-申报文件自动填表` 开发分支,并确认工作区状态 | +| 开发步骤 | 1. 检查当前分支和 `git status`;2. 确认第三批设计文档存在;3. 创建开发分支;4. 记录已有未提交变更,不得回滚用户变更 | +| 验收标准 | 分支创建成功,工作区变更来源清楚 | +| 验证命令 | `git branch --show-current`; `git status --short` | +| Codex 执行提示 | 请创建第三批自动填表开发分支,检查当前工作区状态和设计文档,不要回滚用户已有变更。 | + +### AFF-0-002 运行基线回归 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 测试 / 回归 | +| 前置任务 | AFF-0-001 | +| 涉及文件 | 无固定文件 | +| 目标 | 确认现有文件汇总和法规核查主流程在开发前可用 | +| 开发步骤 | 1. 运行 Django check;2. 运行文件汇总测试;3. 运行法规核查测试;4. 记录失败项并先判断是否为既有问题 | +| 验收标准 | 关键回归测试通过,或记录清楚既有失败和本阶段处理策略 | +| 验证命令 | `python manage.py check`; `pytest tests/test_file_summary_*.py tests/test_regulatory_*.py` | +| Codex 执行提示 | 请在开发前运行 Django check 和文件汇总/法规核查关键测试,确认基线稳定。若存在既有失败,请记录,不要直接改无关代码。 | + +--- + +## 六、AFF-1 数据模型与通用导出扩展 + +### AFF-1-001 新增自动填表 ORM 模型 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 数据库 / 后端 | +| 前置任务 | AFF-0 | +| 涉及文件 | `review_agent/models.py` | +| 目标 | 新增 `ApplicationFormFillBatch`、`ApplicationFormFillArtifact`、`ApplicationFormFillNotificationRecord` | +| 开发步骤 | 1. 定义批次状态枚举;2. 定义产物类型枚举;3. 定义通知状态和渠道枚举;4. 添加外键到 Conversation、User、Message、FileSummaryBatch、RegulatoryReviewBatch;5. 添加 JSONField、hash、路径、时间字段;6. 添加 `db_table`、索引和唯一约束 | +| 验收标准 | 模型字段、表名、索引与数据库设计一致 | +| 验证命令 | `python manage.py check` | +| Codex 执行提示 | 请按 `docs/4.数据库设计/3.产品关键信息提取与申报文件自动填表.md` 新增自动填表三张表模型,模型集中放在 `review_agent/models.py`。 | + +### AFF-1-002 扩展导出类型和权限查询能力 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 数据库 / 下载 | +| 前置任务 | AFF-1-001 | +| 涉及文件 | `review_agent/models.py`、导出下载权限相关视图 | +| 目标 | 为 `ExportedSummaryFile.ExportType` 增加 `word`、`pdf`,并确保下载权限支持 `application_form_fill` | +| 开发步骤 | 1. 扩展 `ExportType.WORD`;2. 扩展 `ExportType.PDF`;3. 检查下载接口按 workflow_type 分派权限;4. 增加 application_form_fill 反查批次的权限路径 | +| 验收标准 | Word/ PDF 导出记录可创建;填表导出下载权限可追溯到当前用户 | +| 验证命令 | `python manage.py check`; `pytest tests/test_file_summary_views.py -k download` | +| Codex 执行提示 | 请扩展 ExportedSummaryFile 支持 word/pdf,并让现有下载接口能通过 workflow_type=application_form_fill 校验填表批次权限。 | + +### AFF-1-003 生成迁移并补模型测试 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 数据库 / 测试 | +| 前置任务 | AFF-1-002 | +| 涉及文件 | `review_agent/migrations/`、`tests/test_application_form_fill_models.py` | +| 目标 | 生成迁移并覆盖新增表的基础约束和权限关系 | +| 开发步骤 | 1. 运行 makemigrations;2. 检查 migration 只包含第三批相关变更;3. 运行 migrate;4. 测试批次创建;5. 测试产物 hash 字段;6. 测试通知重试字段;7. 测试 ExportedSummaryFile word 类型 | +| 验收标准 | migration 可执行,模型测试通过 | +| 验证命令 | `python manage.py makemigrations review_agent`; `python manage.py migrate`; `pytest tests/test_application_form_fill_models.py` | +| Codex 执行提示 | 请为第三批模型生成迁移并新增模型测试,覆盖批次、产物、通知记录和 word/pdf 导出类型。 | + +### AFF-1 阶段验证 + +```bash +python manage.py check +pytest tests/test_application_form_fill_models.py tests/test_file_summary_views.py -k download +``` + +--- + +## 七、AFF-2 模块骨架与模板配置 + +### AFF-2-001 创建 application_form_fill 模块骨架 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 模块 | +| 前置任务 | AFF-1 | +| 涉及文件 | `review_agent/application_form_fill/` | +| 目标 | 建立独立模块目录、常量、schemas、storage、workflow、views 和 services 包 | +| 开发步骤 | 1. 创建模块目录;2. 创建 `constants.py`;3. 创建 `schemas.py`;4. 创建 `storage.py`;5. 创建 `workflow.py`;6. 创建 `views.py`;7. 创建 services 子模块;8. 创建 templates 和 prompts 目录 | +| 验收标准 | 模块可 import,不影响既有应用启动 | +| 验证命令 | `python manage.py check` | +| Codex 执行提示 | 请新增 `review_agent/application_form_fill/` 独立模块骨架,先只放常量、schema、空服务和基础 import,不要改动法规核查模块。 | + +### AFF-2-002 编写模板配置 YAML + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 配置 / 模板 | +| 前置任务 | AFF-2-001 | +| 涉及文件 | `review_agent/application_form_fill/templates/application_form_templates_v1.yaml` | +| 目标 | 建立模板配置,至少覆盖注册证 `.docx` 已识别字段 | +| 开发步骤 | 1. 定义 version;2. 定义 source_dir;3. 配置 `registration_certificate`;4. 配置 `change_registration` 为 `.doc` 待转换模板;5. 配置 `essential_principles` 为 `.doc` 待转换模板;6. 为注册证配置注册人名称、注册人住所、生产地址、产品名称、包装规格、主要组成成分、预期用途、储存条件及有效期、附件等字段 | +| 验收标准 | YAML 可解析,注册证字段映射到 table_row | +| 验证命令 | `pytest tests/test_application_form_fill_template_config.py` | +| Codex 执行提示 | 请新增自动填表模板配置 YAML,配置路径必须是 `review_agent/application_form_fill/templates/application_form_templates_v1.yaml`,先完整录入注册证表格字段。 | + +### AFF-2-003 实现模板配置加载与校验 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 配置 | +| 前置任务 | AFF-2-002 | +| 涉及文件 | `review_agent/application_form_fill/services/template_config.py`、`tests/test_application_form_fill_template_config.py` | +| 目标 | 读取、校验模板配置并计算 hash | +| 开发步骤 | 1. 实现 `load_template_config()`;2. 实现 `validate_template_config()`;3. 实现 `compute_config_hash()`;4. 校验 version、source_dir、templates、code 唯一、source_file 存在、target.type 支持;5. 对 `.doc` 待转换模板允许配置存在但标记运行时处理 | +| 验收标准 | 有效配置通过,缺失 source_dir 或重复 code 能被测试捕获 | +| 验证命令 | `pytest tests/test_application_form_fill_template_config.py` | +| Codex 执行提示 | 请实现模板配置加载和校验服务,配置错误必须返回清晰错误列表,不要在 import 时直接崩溃。 | + +### AFF-2 阶段验证 + +```bash +python manage.py check +pytest tests/test_application_form_fill_template_config.py +``` + +--- + +## 八、AFF-3 触发与工作流骨架 + +### AFF-3-001 扩展意图路由 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 意图识别 | +| 前置任务 | AFF-2 | +| 涉及文件 | `review_agent/skill_router.py`、`review_agent/application_form_fill/constants.py`、`tests/test_application_form_fill_trigger.py` | +| 目标 | 用户话语命中自动填表意图时返回 `application_form_fill` | +| 开发步骤 | 1. 增加触发关键词;2. 支持“帮我填注册证”“对应的表格”“生成申报模板”等;3. 支持指定模板识别入口;4. 保持文件汇总和法规核查路由不回归 | +| 验收标准 | 自动填表语句触发正确,普通对话不误触发 | +| 验证命令 | `pytest tests/test_application_form_fill_trigger.py tests/test_regulatory_workflow.py -k router` | +| Codex 执行提示 | 请扩展现有意图路由,新增 application_form_fill 动作。不要破坏 file_summary 和 regulatory_review 的现有触发。 | + +### AFF-3-002 实现批次创建和节点初始化 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 工作流 | +| 前置任务 | AFF-3-001 | +| 涉及文件 | `review_agent/application_form_fill/workflow.py`、`review_agent/application_form_fill/storage.py`、`tests/test_application_form_fill_workflow.py` | +| 目标 | 创建填表批次、生成工作目录、初始化节点 | +| 开发步骤 | 1. 实现 `build_batch_no()`;2. 实现 `build_batch_work_dir()`;3. 实现 `create_application_form_fill_batch()`;4. 绑定 conversation、user、trigger_message、source_summary_batch;5. 初始化 `FORM_FILL_NODE_DEFINITIONS` 节点;6. 写 workflow_created 事件 | +| 验收标准 | 批次编号唯一,节点数量正确,工作目录在受控路径 | +| 验证命令 | `pytest tests/test_application_form_fill_workflow.py -k create` | +| Codex 执行提示 | 请实现自动填表批次创建和节点初始化,workflow_type 必须写 application_form_fill。 | + +### AFF-3-003 实现工作流执行器骨架 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 工作流 | +| 前置任务 | AFF-3-002 | +| 涉及文件 | `review_agent/application_form_fill/workflow.py`、`tests/test_application_form_fill_workflow.py` | +| 目标 | 实现节点串行执行、状态更新、事件推送和 skipped PDF 节点 | +| 开发步骤 | 1. 实现 `FormFillWorkflowExecutor.run()`;2. 实现 `_nodes()`;3. 实现 `_run_node()`;4. 每个节点写 running/success/skipped;5. `pdf_convert` 本期标记 skipped;6. 失败时写 batch.failed | +| 验收标准 | 空实现节点可完整跑到 success;PDF 节点 skipped | +| 验证命令 | `pytest tests/test_application_form_fill_workflow.py -k executor` | +| Codex 执行提示 | 请实现自动填表工作流执行器骨架,先让节点状态可完整流转,PDF 转换节点本期标记 skipped。 | + +### AFF-3-004 接入流式对话启动逻辑 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 对话 | +| 前置任务 | AFF-3-003 | +| 涉及文件 | `review_agent/services.py`、`review_agent/application_form_fill/views.py` | +| 目标 | 用户触发自动填表时启动工作流;有附件时先自动汇总,无附件时使用最近成功汇总批次 | +| 开发步骤 | 1. 在 stream_message 中处理 application_form_fill 路由;2. 如本次存在新附件,复用文件汇总启动逻辑;3. 无新附件时查找最近成功 `FileSummaryBatch`;4. 无来源批次时回复请上传资料;5. 返回 workflow meta | +| 验收标准 | 对话触发能创建填表批次;无汇总批次时不崩溃 | +| 验证命令 | `pytest tests/test_application_form_fill_workflow.py -k stream` | +| Codex 执行提示 | 请把 application_form_fill 接入现有 stream_message。无附件时复用最近成功汇总批次;有新附件时先自动汇总。 | + +### AFF-3 阶段验证 + +```bash +python manage.py check +pytest tests/test_application_form_fill_trigger.py tests/test_application_form_fill_workflow.py +``` + +--- + +## 九、AFF-4 模板选择与文件来源 + +### AFF-4-001 实现模板指定解析 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 模板选择 | +| 前置任务 | AFF-3 | +| 涉及文件 | `review_agent/application_form_fill/services/template_select.py`、`tests/test_application_form_fill_template_select.py` | +| 目标 | 从用户话语中识别指定模板 | +| 开发步骤 | 1. 识别注册证;2. 识别变更注册备案文件;3. 识别安全和性能基本原则清单;4. 识别全部模板;5. 未指定返回空数组 | +| 验收标准 | 指定模板语句可返回正确 template_codes | +| 验证命令 | `pytest tests/test_application_form_fill_template_select.py -k requested` | +| Codex 执行提示 | 请实现用户指定模板解析,支持注册证、变更注册备案文件、安全和性能基本原则清单、全部模板。 | + +### AFF-4-002 实现注册类型识别和模板选择 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 模板选择 | +| 前置任务 | AFF-4-001 | +| 涉及文件 | `review_agent/application_form_fill/services/template_select.py`、`tests/test_application_form_fill_template_select.py` | +| 目标 | 按用户话语、法规确认条件、文件抽取识别注册类型,并选择模板 | +| 开发步骤 | 1. 用户话语识别首次注册、变更注册、备案;2. 从 `source_regulatory_batch.condition_json` 读取 confirmed_conditions;3. 从文件抽取候选读取 registration_type;4. 未指定模板时首次注册生成注册证 + 基本原则清单;5. 变更/备案生成变更文件 + 基本原则清单;6. 指定不适用模板允许生成但写 risk_notes | +| 验收标准 | 模板选择规则与功能设计一致 | +| 验证命令 | `pytest tests/test_application_form_fill_template_select.py` | +| Codex 执行提示 | 请实现注册类型识别和默认模板选择,优先级是用户话语、已确认法规核查条件、文件抽取、unknown。 | + +### AFF-4-003 实现模板复制服务 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 模板 | +| 前置任务 | AFF-4-002 | +| 涉及文件 | `review_agent/application_form_fill/services/template_repository.py`、`review_agent/application_form_fill/storage.py`、`tests/test_application_form_fill_template_repository.py` | +| 目标 | 将原始模板复制到批次目录,原始模板只读 | +| 开发步骤 | 1. 根据 TemplateSpec 定位 source_file;2. 复制到 `work_dir/templates`;3. 记录 ApplicationFormFillArtifact(template_copy);4. `.doc` 且无工作模板时返回模板失败,不影响其他模板;5. 路径必须在受控工作目录内 | +| 验收标准 | 注册证 `.docx` 可复制;原始文件不被修改;产物 hash 写入 | +| 验证命令 | `pytest tests/test_application_form_fill_template_repository.py` | +| Codex 执行提示 | 请实现模板复制服务,只允许复制到批次工作目录,不能直接写原始法规材料目录。 | + +### AFF-4 阶段验证 + +```bash +pytest tests/test_application_form_fill_template_select.py tests/test_application_form_fill_template_repository.py +``` + +--- + +## 十、AFF-5 字段抽取与合并 + +### AFF-5-001 实现规则/正则字段抽取 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 字段抽取 | +| 前置任务 | AFF-4 | +| 涉及文件 | `review_agent/application_form_fill/services/field_extract.py`、`tests/test_application_form_fill_field_extract.py` | +| 目标 | 从说明书、产品技术要求等文本中按标签和章节抽取字段 | +| 开发步骤 | 1. 复用 `regulatory_review.services.text_extract.extract_text`;2. 识别文件角色;3. 匹配 `字段名:值` 标签行;4. 支持多行值拼接;5. 保存 source_file、source_role、evidence、confidence、extractor=rule | +| 验收标准 | 能从测试说明书文本抽取产品名称、预期用途、储存条件、有效期、包装规格 | +| 验证命令 | `pytest tests/test_application_form_fill_field_extract.py -k rules` | +| Codex 执行提示 | 请实现自动填表规则/正则字段抽取,优先覆盖注册证模板字段,抽取结果必须包含来源文件、来源角色和证据片段。 | + +### AFF-5-002 实现 LLM 结构化抽取封装 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / LLM | +| 前置任务 | AFF-5-001 | +| 涉及文件 | `review_agent/application_form_fill/services/field_extract.py`、`review_agent/application_form_fill/prompts/field_extract.md`、`tests/test_application_form_fill_field_extract.py` | +| 目标 | 调用现有 LLM 能力输出字段 JSON,失败时降级 | +| 开发步骤 | 1. 编写字段抽取 prompt;2. 输入模板字段、文件上下文和候选文本;3. 要求输出 JSON fields/checklist_items;4. 解析 JSON;5. 捕获超时和解析失败;6. 失败返回空 LLM 结果,不阻断规则抽取 | +| 验收标准 | monkeypatch LLM 后可解析结构化字段;LLM 异常时工作流继续 | +| 验证命令 | `pytest tests/test_application_form_fill_field_extract.py -k llm` | +| Codex 执行提示 | 请实现 LLM 结构化抽取封装,必须可测试、可降级。LLM 输出解析失败不能导致整个填表批次失败。 | + +### AFF-5-003 实现并行抽取和产物留底 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 字段抽取 | +| 前置任务 | AFF-5-002 | +| 涉及文件 | `review_agent/application_form_fill/services/field_extract.py`、`review_agent/application_form_fill/storage.py` | +| 目标 | 并行执行规则/正则和 LLM 抽取,并保存 `field_extract_result.json` | +| 开发步骤 | 1. 使用 ThreadPoolExecutor;2. 规则和 LLM 两路并行;3. 组装 regex_results、llm_results、selected_templates、source_evidence;4. 保存 JSON;5. 写 ApplicationFormFillArtifact(field_extract_result) | +| 验收标准 | JSON 产物包含两路结果和模板列表 | +| 验证命令 | `pytest tests/test_application_form_fill_field_extract.py -k parallel` | +| Codex 执行提示 | 请实现字段并行抽取和 field_extract_result.json 产物留底,LLM 失败时也必须保存规则结果。 | + +### AFF-5-004 实现字段合并与冲突检测 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 字段合并 | +| 前置任务 | AFF-5-003 | +| 涉及文件 | `review_agent/application_form_fill/services/field_merge.py`、`tests/test_application_form_fill_field_merge.py` | +| 目标 | 合并规则和 LLM 字段,说明书优先,并生成冲突摘要 | +| 开发步骤 | 1. 实现字段值归一化;2. 实现来源优先级排序;3. 同字段多值一致时合并;4. 不一致时选择最高优先级来源;5. 说明书与其他文件冲突时标记 conflict;6. 输出 merged_fields 和 conflicts | +| 验收标准 | 说明书优先;冲突字段包含 selected_value、selected_source、conflict_values、handling | +| 验证命令 | `pytest tests/test_application_form_fill_field_merge.py` | +| Codex 执行提示 | 请实现字段合并服务,严格按说明书优先处理冲突,并把冲突列表写成可用于对话摘要和追溯清单的结构。 | + +### AFF-5 阶段验证 + +```bash +pytest tests/test_application_form_fill_field_extract.py tests/test_application_form_fill_field_merge.py +``` + +--- + +## 十一、AFF-6 Word 填充与追溯导出 + +### AFF-6-001 实现 Word 表格行填充 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / Word | +| 前置任务 | AFF-5 | +| 涉及文件 | `review_agent/application_form_fill/services/word_fill.py`、`tests/test_application_form_fill_word_fill.py` | +| 目标 | 使用 `python-docx` 按表格行名写入注册证模板 | +| 开发步骤 | 1. 打开 docx 模板副本;2. 遍历 tables/rows/cells;3. 匹配第一列 row_label;4. 写入第二列;5. 缺失字段保持空白;6. 保存 output_path | +| 验收标准 | 产品名称、包装规格、预期用途等能写入注册证模板对应行 | +| 验证命令 | `pytest tests/test_application_form_fill_word_fill.py -k table` | +| Codex 执行提示 | 请实现 Word 表格行填充服务,先支持注册证模板的两列表格行名匹配。 | + +### AFF-6-002 实现冲突高亮 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / Word | +| 前置任务 | AFF-6-001 | +| 涉及文件 | `review_agent/application_form_fill/services/word_fill.py`、`tests/test_application_form_fill_word_fill.py` | +| 目标 | 冲突字段在 Word 中黄底红字 | +| 开发步骤 | 1. 对冲突字段写入 run;2. 设置字体颜色 `FF0000`;3. 设置单元格 shading `FFFF00`;4. 非冲突字段保持原样式;5. 测试读取 docx XML 验证颜色和底色 | +| 验收标准 | 冲突字段样式可在 docx XML 中验证 | +| 验证命令 | `pytest tests/test_application_form_fill_word_fill.py -k highlight` | +| Codex 执行提示 | 请实现 Word 冲突高亮,冲突字段必须红色字体和黄色底色,测试需检查 docx XML。 | + +### AFF-6-003 创建 Word 导出记录 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 导出 | +| 前置任务 | AFF-6-002 | +| 涉及文件 | `review_agent/application_form_fill/services/word_fill.py`、`review_agent/application_form_fill/workflow.py` | +| 目标 | Word 生成后写入 `ExportedSummaryFile(export_type=word)` 和产物记录 | +| 开发步骤 | 1. 按批次号、产品名、模板标签生成文件名;2. 保存到 `work_dir/filled`;3. 创建 `ApplicationFormFillArtifact(filled_template)`;4. 创建 `ExportedSummaryFile`;5. 记录模板失败时错误 | +| 验收标准 | 可查询到 word 导出记录和 filled_template 产物 | +| 验证命令 | `pytest tests/test_application_form_fill_word_fill.py -k export` | +| Codex 执行提示 | 请把 Word 填充结果保存为导出文件,export_type 使用 word,workflow_type 使用 application_form_fill。 | + +### AFF-6-004 实现追溯清单 Excel/JSON + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 导出 | +| 前置任务 | AFF-6-003 | +| 涉及文件 | `review_agent/application_form_fill/services/traceability_export.py`、`tests/test_application_form_fill_traceability.py` | +| 目标 | 输出字段来源追溯清单和合并结果 JSON | +| 开发步骤 | 1. 生成“字段追溯”Sheet;2. 生成“冲突字段”Sheet;3. 生成“低置信度条目”Sheet;4. 生成“生成结果”Sheet;5. 保存 Excel;6. 保存 merged_fields.json;7. 创建导出和产物记录 | +| 验收标准 | Excel 可打开,包含字段、来源、证据、冲突、处理方式 | +| 验证命令 | `pytest tests/test_application_form_fill_traceability.py` | +| Codex 执行提示 | 请实现字段来源追溯清单导出,必须包含规则/LLM 合并结果、冲突字段和生成结果。 | + +### AFF-6 阶段验证 + +```bash +pytest tests/test_application_form_fill_word_fill.py tests/test_application_form_fill_traceability.py +``` + +--- + +## 十二、AFF-7 飞书通知与对话摘要 + +### AFF-7-001 生成助手 Markdown 摘要 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 对话 | +| 前置任务 | AFF-6 | +| 涉及文件 | `review_agent/application_form_fill/services/traceability_export.py`、`review_agent/application_form_fill/workflow.py` | +| 目标 | 工作流完成后向当前对话写入下载链接和冲突摘要 | +| 开发步骤 | 1. 汇总 Word 导出;2. 汇总 PDF 状态为待增强;3. 汇总冲突字段;4. 添加追溯清单下载链接;5. 创建 assistant Message | +| 验收标准 | 对话中出现 Markdown 表格、Word 下载、追溯清单下载和冲突摘要 | +| 验证命令 | `pytest tests/test_application_form_fill_workflow.py -k summary` | +| Codex 执行提示 | 请实现自动填表完成后的助手 Markdown 摘要,PDF 本期显示为待增强,不作为失败。 | + +### AFF-7-002 实现飞书通知记录和 mock 通知 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 通知 | +| 前置任务 | AFF-7-001 | +| 涉及文件 | `review_agent/application_form_fill/services/notifier.py`、`tests/test_application_form_fill_notification.py` | +| 目标 | 填表完成后记录通知,可 mock 发送,失败不阻断下载 | +| 开发步骤 | 1. 实现 `notify_completion()`;2. 默认 channel=mock;3. 写 template_codes、export_ids、message_summary;4. 支持 send_status success/failed;5. 失败时记录 error_message 和 retry_count | +| 验收标准 | 通知记录可查;通知失败不影响批次核心产物 | +| 验证命令 | `pytest tests/test_application_form_fill_notification.py` | +| Codex 执行提示 | 请实现自动填表通知服务,先用 mock 通知记录即可。通知失败不得阻断 Word 下载。 | + +### AFF-7-003 完成工作流状态归并 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 工作流 | +| 前置任务 | AFF-7-002 | +| 涉及文件 | `review_agent/application_form_fill/workflow.py`、`tests/test_application_form_fill_workflow.py` | +| 目标 | 根据 Word、追溯清单、通知结果标记 success/partial_success/failed | +| 开发步骤 | 1. 所有目标 Word 成功时 success;2. 至少一个 Word 成功但非关键产物失败时 partial_success;3. 所有 Word 失败时 failed;4. PDF skipped 不导致失败;5. 发送 workflow_completed 事件 | +| 验收标准 | 批次状态符合详细设计 | +| 验证命令 | `pytest tests/test_application_form_fill_workflow.py -k status` | +| Codex 执行提示 | 请完成自动填表工作流状态归并,PDF skipped 不影响 success,通知失败最多导致 partial_success。 | + +### AFF-7 阶段验证 + +```bash +pytest tests/test_application_form_fill_workflow.py tests/test_application_form_fill_notification.py +``` + +--- + +## 十三、AFF-8 前端卡片与总体验收 + +### AFF-8-001 后端状态接口 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 接口 | +| 前置任务 | AFF-7 | +| 涉及文件 | `review_agent/application_form_fill/views.py`、`review_agent/urls.py` 或相关 URL 文件 | +| 目标 | 提供自动填表启动和状态查询接口 | +| 开发步骤 | 1. 新增 start 接口;2. 新增 detail/status 接口;3. 返回 batch、nodes、conflicts、exports;4. 校验 conversation/user 权限;5. 接入 URL | +| 验收标准 | 当前用户可查自己的填表批次,不能查他人批次 | +| 验证命令 | `pytest tests/test_application_form_fill_views.py` | +| Codex 执行提示 | 请实现自动填表启动和状态查询接口,所有查询必须校验当前用户权限。 | + +### AFF-8-002 前端支持 application_form_fill 卡片 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 前端 / 工作流卡片 | +| 前置任务 | AFF-8-001 | +| 涉及文件 | `static/js/app.js`、`templates/home.html`、静态 CSS 文件 | +| 目标 | 前端展示自动填表工作流卡片,并根据 SSE 更新节点 | +| 开发步骤 | 1. 解析 workflow_type=application_form_fill;2. 定义节点文案;3. 创建卡片;4. 更新节点状态;5. PDF 节点显示待增强/跳过;6. 页面刷新后恢复 | +| 验收标准 | 自动填表卡片可显示准备资料、选择模板、复制模板、抽取字段、填写 Word、追溯清单、飞书通知 | +| 验证命令 | `pytest tests/test_application_form_fill_frontend.py` 或现有前端测试命令 | +| Codex 执行提示 | 请在现有工作流卡片逻辑中新增 application_form_fill 类型,展示自动填表节点并支持状态恢复。 | + +### AFF-8-003 前端展示结果和下载链接 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 前端 / Markdown | +| 前置任务 | AFF-8-002 | +| 涉及文件 | `static/js/app.js`、模板和 CSS | +| 目标 | 对话框正常展示 Word 下载、追溯清单、冲突摘要 | +| 开发步骤 | 1. 确认助手 Markdown 渲染支持表格;2. 验证 Word 下载链接点击;3. 验证冲突摘要表格;4. PDF 列显示待增强 | +| 验收标准 | 对话结果可读、链接可用、PDF 待增强不被误判为失败 | +| 验证命令 | 前端/Playwright 对应测试 | +| Codex 执行提示 | 请验证并完善自动填表结果展示,确保 Markdown 表格、Word 下载链接、追溯清单链接和冲突摘要正常显示。 | + +### AFF-8-004 总体验收与回归 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 验收 / 回归 | +| 前置任务 | AFF-8-003 | +| 涉及文件 | 全项目 | +| 目标 | 运行全量测试,确认前三批能力均不回归 | +| 开发步骤 | 1. 运行 Django check;2. 运行自动填表测试;3. 运行文件汇总测试;4. 运行法规核查测试;5. 如可用,运行前端/Playwright 测试;6. 检查 git status | +| 验收标准 | 全量测试通过;失败项均有解释;无意外文件变更 | +| 验证命令 | `python manage.py check`; `pytest` | +| Codex 执行提示 | 请执行第三批自动填表总体验收,运行 Django check 和 pytest 全量回归,确认文件汇总与法规核查不回归。 | + +### AFF-8 阶段验证 + +```bash +python manage.py check +pytest +``` + +--- + +## 十四、测试分层要求 + +| 层级 | 验证内容 | 建议文件 | +| --- | --- | --- | +| 模型测试 | 三张新表、word/pdf 导出类型、权限关系 | `tests/test_application_form_fill_models.py` | +| 配置测试 | YAML 加载、模板配置校验、hash | `tests/test_application_form_fill_template_config.py` | +| 选择测试 | 触发语句、指定模板、注册类型优先级、默认模板 | `tests/test_application_form_fill_template_select.py` | +| 抽取测试 | 规则/正则、LLM 降级、并行抽取、字段合并 | `tests/test_application_form_fill_field_extract.py`、`tests/test_application_form_fill_field_merge.py` | +| Word 测试 | 表格行填充、冲突高亮、导出记录 | `tests/test_application_form_fill_word_fill.py` | +| 导出测试 | 追溯清单 Excel、JSON 产物、下载权限 | `tests/test_application_form_fill_traceability.py`、`tests/test_application_form_fill_views.py` | +| 工作流测试 | 批次创建、节点流转、状态归并、助手摘要 | `tests/test_application_form_fill_workflow.py` | +| 通知测试 | mock 通知、失败记录、重试字段 | `tests/test_application_form_fill_notification.py` | +| 前端测试 | 卡片节点、PDF 待增强、下载链接、冲突摘要 | `tests/test_application_form_fill_frontend.py` | + +--- + +## 十五、Codex 自动化执行规则 + +| 规则 | 内容 | +| --- | --- | +| 顺序执行 | 必须从 AFF-0 到 AFF-8 顺序执行,不得跳阶段 | +| TDD | 新行为先写失败测试,再实现 | +| 当前阶段优先 | 某阶段失败时先修复当前阶段,不继续后续阶段 | +| 回归保护 | 文件汇总和法规核查已有测试不得回归 | +| PDF 边界 | PDF 节点本期可 skipped,不为 PDF 引入强依赖 | +| 字段表边界 | 不新增字段级数据库表,后续增强已在待办计划 | +| 每阶段验证 | 每阶段完成后运行对应验证命令 | +| 每阶段提交 | 每阶段验证通过后生成提交摘要并本地提交 | +| 不覆盖变更 | 不得回滚或覆盖用户已有未提交变更 | + +--- + +## 十六、推荐目标模式提示词 + +后续可直接对 Codex 输入: + +```text +请按 docs/5.开发计划/3.产品关键信息提取与申报文件自动填表.md 执行第三批开发。 + +目标: +完成独立 application_form_fill 工作流,通过用户对话触发自动填表,复用当前对话最近成功 FileSummaryBatch,支持模板配置、注册证 Word 自动填写、规则/正则与 LLM 并行字段抽取、说明书优先冲突归并、冲突高亮、字段来源追溯清单、Word 下载、自动填表工作流卡片和飞书 mock 通知记录。 + +执行规则: +1. 创建 codex/YYYYMMDD-申报文件自动填表 分支。 +2. 按 AFF-0 到 AFF-8 顺序执行,不跳阶段。 +3. 每阶段先写测试,再实现,完成后运行对应验证命令。 +4. 不实现字段级数据库表。 +5. PDF 转换本期作为 skipped/待增强,不引入强制 LibreOffice 依赖。 +6. 模板配置路径必须为 review_agent/application_form_fill/templates/application_form_templates_v1.yaml。 +7. Word 模板优先支持注册证格式 docx,两个 doc 模板可标记待转换或部分成功。 +8. 每阶段验证通过后调用 git-commit-summary 生成提交摘要并本地提交。 +9. 最后运行 python manage.py check 和 pytest 全量验收。 +``` + +--- + +## 十七、待执行前检查清单 + +| 检查项 | 状态 | +| --- | --- | +| 第三批需求分析、功能设计、详细设计、数据库设计均已存在 | 待执行时确认 | +| 当前分支是否适合创建开发分支 | 待执行时确认 | +| 是否存在用户未提交变更 | 待执行时确认 | +| `python-docx`、`openpyxl`、`PyYAML` 是否可用 | 待执行时确认 | +| 现有文件汇总和法规核查测试是否通过 | 待执行时确认 | +| 执行机器是否提供 `git-commit-summary` skill | 待执行时确认 | +| `.doc` 模板和 PDF 转换是否保持在待办边界内 | 待执行时确认 | diff --git a/docs/5.开发计划/4.飞书通知与问答接入.md b/docs/5.开发计划/4.飞书通知与问答接入.md new file mode 100644 index 0000000..10abeab --- /dev/null +++ b/docs/5.开发计划/4.飞书通知与问答接入.md @@ -0,0 +1,583 @@ +# 飞书通知与问答接入开发计划 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/4.飞书通知与问答接入.md | +| 功能设计文档 | docs/2.功能设计/4.飞书通知与问答接入.md | +| 详细设计文档 | docs/3.详细设计/4.飞书通知与问答接入.md | +| 数据库设计文档 | docs/4.数据库设计/4.飞书通知与问答接入.md | +| 功能名称 | 飞书通知与问答接入 | +| 所属模块 | 审核智能体 review_agent | +| 执行方式 | 单人开发 + Codex 自动化执行 | +| 计划日期 | 2026-06-07 | +| 计划版本 | V1.0 | + +--- + +## Codex 自动执行说明 + +本文件用于 Codex 自动执行开发任务。执行时必须按阶段顺序推进,不得跳过测试、不得直接请求真实飞书接口作为自动化测试、不得把真实 App Secret 或 token 写入代码库。 + +执行规则: + +| 规则 | 要求 | +| --- | --- | +| 执行顺序 | 必须从 FS-0 到 FS-8 顺序执行,前一阶段验证未通过不得进入下一阶段 | +| TDD | 每个服务、模型、命令和页面展示任务必须先写失败测试,再实现代码,再运行测试确认通过 | +| 外部 API | 自动化测试必须 mock 飞书 token API 和消息 API;真实飞书只通过 `send_test_feishu_notification` 手动命令验证 | +| 凭证安全 | 不得提交真实 `FEISHU_APP_ID`、`FEISHU_APP_SECRET`、`tenant_access_token`、用户 open_id/user_id | +| 失败处理 | 如测试失败,先定位是否由本阶段改动引起;不得修改无关功能绕过测试 | +| 工作区安全 | 不得回滚用户已有变更;如遇到同文件用户改动,先阅读并兼容 | +| 提交节奏 | 每个阶段完成并通过阶段验证后再提交,提交信息参考“建议提交切分” | +| 实现边界 | 首期只做指定个人账号私聊通知和问答预留;不得扩展外部群聊、事件订阅、LLM 问答解析 | + +自动执行入口建议: + +```text +请按 docs/5.开发计划/4.飞书通知与问答接入.md 从 FS-0 开始逐阶段执行。 +每个阶段必须先写测试、运行失败、实现、运行通过,再进入下一阶段。 +真实飞书 API 只能通过手动 management command 验证,pytest 中必须 mock。 +``` + +--- + +## 一、开发计划目标 + +本开发计划用于指导“飞书通知与问答接入”首期开发。首期目标是通过飞书官方智能体/应用机器人接口,把系统中三个工作流的结束结果发送到指定个人飞书账号,并为后续飞书内问答建立可测试的最小服务边界。 + +本期必须完成: + +| 类别 | 内容 | +| --- | --- | +| 真实飞书通知 | 使用 App ID/App Secret 获取 `tenant_access_token`,调用飞书消息 API 发送私聊通知 | +| 指定个人账号 | 通过 `.env` 配置 `FEISHU_DEFAULT_USER_OPEN_ID` 或 `FEISHU_DEFAULT_USER_ID`,首期优先发给该账号 | +| 三流程接入 | 自动汇总、法规核查、自动填表三个流程完成后均触发通知 | +| 数据库记录 | 新增统一通知记录表、飞书用户映射表、token 缓存表、问答日志表 | +| 页面展示 | 三个流程结果页或详情区域展示飞书通知状态 | +| 问答预留 | 建表、实现批次摘要查询、简单规则意图解析、本地模拟问答命令 | +| 测试策略 | 关键服务严格 TDD;自动化测试 mock 飞书 API;真实飞书发送通过 management command 手动验证 | + +本期明确不做: + +| 类别 | 内容 | +| --- | --- | +| 外部群聊接入 | 暂不向群聊发送通知,不做群内 @ | +| 飞书事件订阅 | 暂不接收飞书回调,不实现真实私聊问答事件入口 | +| 手动重发 | 页面和 Admin 暂不提供重发按钮 | +| 自动后台重试 | 通知失败只记录;成功才判重,失败允许后续再次发送 | +| LLM 问答解析 | 问答预留只做简单规则解析,不接 LLM | + +--- + +## 二、已确认开发规则 + +| 规则项 | 内容 | +| --- | --- | +| 主接入方式 | 飞书官方智能体/应用机器人消息 API | +| 凭证配置 | `.env` 提供 `FEISHU_APP_ID`、`FEISHU_APP_SECRET` | +| 接收人配置 | `.env` + Django Admin 都做;首期发送优先使用 `.env` 指定个人账号 | +| 接收人优先级 | `FEISHU_DEFAULT_USER_OPEN_ID` > `FEISHU_DEFAULT_USER_ID` | +| token 缓存 | 数据库缓存 `tenant_access_token` 和过期时间 | +| 通知记录 | 新增统一 `WorkflowNotificationRecord`,三个流程都写入 | +| 判重策略 | 同一批次、同一流程、同一状态,只有成功记录才判重;失败后允许再次发送 | +| 系统链接 | 新增 `PUBLIC_BASE_URL`,默认 `http://127.0.0.1:8000` | +| 页面展示 | 三个流程结果页或详情区域展示通知状态 | +| 真实 API 测试 | 自动化测试全部 mock;新增 management command 手动发送真实测试消息 | +| TDD | 每个核心模块先写测试再实现 | +| 环境变量说明 | 写变量名和用途,不写真实值 | +| 阶段提交 | 模型、服务、工作流、页面、命令、测试分阶段提交 | +| 问答预留 | 建 `FeishuQuestionLog`,实现摘要查询、规则解析和本地模拟命令 | + +--- + +## 三、总体验收标准 + +| 类别 | 完成标准 | +| --- | --- | +| 配置 | `.env` 支持 `FEISHU_APP_ID`、`FEISHU_APP_SECRET`、`FEISHU_DEFAULT_USER_OPEN_ID` / `FEISHU_DEFAULT_USER_ID`、`PUBLIC_BASE_URL` | +| token | 系统可获取、缓存、过期刷新 `tenant_access_token`;token API 失败会记录失败通知 | +| 发送 | 手动命令可向指定个人账号发送真实测试消息 | +| 通知 | 三个流程完成后均创建通知记录,并在启用配置时调用飞书消息 API | +| 判重 | 成功记录存在时,同一批次/流程/状态不重复发送;失败记录不阻止再次发送 | +| 失败隔离 | 飞书发送失败不影响业务工作流完成 | +| 页面 | 三个流程结果页或详情区域能看到通知通道、接收人、状态、时间、失败原因 | +| 问答预留 | 本地模拟命令可解析“最新/最近/批次号/工作流关键词”,返回批次摘要并记录日志 | +| 权限 | 普通用户只能查询自己的批次摘要;管理员可查全部 | +| 回归 | 文件汇总、法规核查、自动填表既有测试不回归 | + +--- + +## 四、阶段总览 + +| 阶段 | 名称 | 目标 | 阶段验收 | +| --- | --- | --- | --- | +| FS-0 | 准备与基线 | 确认文档和测试基线 | `python manage.py check` 与关键现有测试通过 | +| FS-1 | 数据模型与配置 | 新增通知、映射、token、问答日志模型和环境配置 | migration、模型测试通过 | +| FS-2 | 飞书 API 基础服务 | token 获取缓存、接收人解析、消息构造、消息 API client | 服务单测通过,全部 mock 外部 HTTP | +| FS-3 | 通知调度与记录 | 统一通知上下文、判重、成功/失败/disabled 落库 | 通知服务测试通过 | +| FS-4 | 三流程接入 | 自动汇总、法规核查、自动填表完成后触发通知 | 三流程通知集成测试通过 | +| FS-5 | 页面展示 | 批次详情或结果区域展示通知状态 | 页面/视图测试通过 | +| FS-6 | 手动真实测试命令 | management command 发送真实飞书测试消息 | 本地配置后可向个人账号发消息 | +| FS-7 | 问答预留能力 | 批次摘要查询、规则意图解析、模拟问答命令、问答日志 | 问答预留测试通过 | +| FS-8 | 文档与全量回归 | 更新环境变量说明,运行全量相关测试 | 回归通过,计划完成 | + +--- + +## 五、FS-0 准备与基线 + +### FS-0-001 确认开发文档和当前工作区 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 准备 / Git | +| 前置任务 | 无 | +| 涉及文件 | 文档文件,不改代码 | +| 目标 | 确认需求、功能、数据库、详细设计和开发计划均存在,并记录当前工作区状态 | +| 开发步骤 | 1. 检查 `git status --short`;2. 确认四份设计文档与本开发计划存在;3. 确认当前未提交变更均为文档或用户已有变更;4. 不回滚任何用户变更 | +| 验收标准 | 工作区状态清楚,可进入开发 | +| 验证命令 | `git status --short` | +| Codex 执行提示 | 请先确认飞书接入四份设计文档和开发计划存在,检查工作区状态,不要回滚用户已有变更。 | + +### FS-0-002 运行基线测试 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 测试 / 回归 | +| 前置任务 | FS-0-001 | +| 涉及文件 | 无固定文件 | +| 目标 | 确认开发前现有主流程可运行 | +| 开发步骤 | 1. 运行 Django check;2. 运行通知相关旧测试;3. 运行三个工作流关键测试;4. 若失败,判断是否既有问题并记录 | +| 验收标准 | 基线通过,或既有失败已记录且不与本功能冲突 | +| 验证命令 | `python manage.py check`; `pytest tests/test_file_summary_workflow.py tests/test_regulatory_notification.py tests/test_application_form_fill_notification.py` | +| Codex 执行提示 | 请运行 Django check 和现有通知/工作流关键测试,确认开发前基线。 | + +--- + +## 六、FS-1 数据模型与配置 + +### FS-1-001 新增飞书接入 ORM 模型测试 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 测试 / 数据库 | +| 前置任务 | FS-0 | +| 涉及文件 | `tests/test_feishu_models.py` | +| 目标 | 先写失败测试,覆盖飞书用户映射、token 缓存、统一通知记录、问答日志 | +| 开发步骤 | 1. 新增 `test_feishu_user_mapping_preferred_identifier`;2. 新增 `test_feishu_access_token_cache_expiry`;3. 新增 `test_workflow_notification_success_dedupe_only_success`;4. 新增 `test_feishu_question_log_records_summary_without_full_answer` | +| 验收标准 | 新测试因模型不存在而失败 | +| 验证命令 | `pytest tests/test_feishu_models.py -q` | +| Codex 执行提示 | 请先为飞书相关模型写失败测试,覆盖接收人标识优先级、数据库 token 缓存、成功判重和问答日志摘要。 | + +### FS-1-002 新增模型 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 数据库 / 后端 | +| 前置任务 | FS-1-001 | +| 涉及文件 | `review_agent/models.py` | +| 目标 | 新增 `FeishuUserMapping`、`FeishuAccessTokenCache`、`WorkflowNotificationRecord`、`FeishuQuestionLog` | +| 开发步骤 | 1. `FeishuUserMapping` 关联系统用户,支持 open_id、user_id、mobile、is_active;2. `FeishuAccessTokenCache` 保存 token、expires_at、app_id_hash、error_message;3. `WorkflowNotificationRecord` 保存 workflow_type、batch_id、batch_no、status、channel、target、send_status、summary、error、sent_at;4. `FeishuQuestionLog` 保存问题、意图、查询对象、回答摘要、权限结果和状态;5. 添加索引和模型方法 | +| 验收标准 | `python manage.py check` 通过 | +| 验证命令 | `python manage.py check` | +| Codex 执行提示 | 请按数据库设计新增四个模型。注意 token 需要数据库缓存,通知判重只对 success 生效。 | + +### FS-1-003 生成迁移并通过模型测试 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 数据库 / 测试 | +| 前置任务 | FS-1-002 | +| 涉及文件 | `review_agent/migrations/`、`tests/test_feishu_models.py` | +| 目标 | 生成 migration,模型测试全部通过 | +| 开发步骤 | 1. 运行 makemigrations;2. 检查 migration 只包含飞书相关表;3. 运行 migrate;4. 运行模型测试 | +| 验收标准 | migration 可执行,模型测试通过 | +| 验证命令 | `python manage.py makemigrations review_agent`; `python manage.py migrate`; `pytest tests/test_feishu_models.py -q` | +| Codex 执行提示 | 请生成飞书相关模型迁移并运行模型测试。 | + +### FS-1-004 注册 Admin 和配置项 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后台 / 配置 | +| 前置任务 | FS-1-003 | +| 涉及文件 | `review_agent/admin.py`、`config/settings.py`、`.env.example` 或 README | +| 目标 | Admin 可维护用户映射;settings 暴露飞书配置;文档只写变量名不写真实值 | +| 开发步骤 | 1. 注册 `FeishuUserMapping`、`WorkflowNotificationRecord`、`FeishuAccessTokenCache`、`FeishuQuestionLog`;2. settings 读取 `FEISHU_NOTIFY_ENABLED`、`FEISHU_APP_ID`、`FEISHU_APP_SECRET`、`FEISHU_DEFAULT_USER_OPEN_ID`、`FEISHU_DEFAULT_USER_ID`、`FEISHU_DEFAULT_TARGET_NAME`、`PUBLIC_BASE_URL`;3. 默认 `PUBLIC_BASE_URL=http://127.0.0.1:8000`;4. 在说明文件中加入变量名和用途 | +| 验收标准 | Django check 通过;Admin 列表可展示字段 | +| 验证命令 | `python manage.py check` | +| Codex 执行提示 | 请注册飞书相关模型到 Admin,并新增环境变量配置说明,不要写入真实凭证。 | + +### FS-1 阶段验证 + +```bash +python manage.py check +pytest tests/test_feishu_models.py -q +``` + +--- + +## 七、FS-2 飞书 API 基础服务 + +### FS-2-001 token 服务 TDD + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 测试 / 服务 | +| 前置任务 | FS-1 | +| 涉及文件 | `tests/test_feishu_api_services.py` | +| 目标 | 先写 token 获取、缓存、过期刷新、失败记录测试 | +| 开发步骤 | 1. mock 飞书 token HTTP 成功;2. 测试首次获取后写数据库缓存;3. 测试未过期时不再请求 HTTP;4. 测试过期后重新请求;5. 测试 token API 失败返回错误对象 | +| 验收标准 | 测试先失败 | +| 验证命令 | `pytest tests/test_feishu_api_services.py -k token -q` | +| Codex 执行提示 | 请先写飞书 tenant_access_token 服务测试,外部 HTTP 必须 mock。 | + +### FS-2-002 实现 token 服务 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 服务 | +| 前置任务 | FS-2-001 | +| 涉及文件 | `review_agent/notifications/feishu_token.py` | +| 目标 | 使用 App ID/App Secret 获取并数据库缓存 `tenant_access_token` | +| 开发步骤 | 1. 定义 `FeishuTokenResult`;2. 检查配置缺失;3. 查询未过期数据库缓存;4. 调用 token API;5. 保存 token 和 expires_at;6. token 失败时返回错误,不抛出到业务流程 | +| 验收标准 | token 服务测试通过 | +| 验证命令 | `pytest tests/test_feishu_api_services.py -k token -q` | +| Codex 执行提示 | 请实现 token 服务,缓存放数据库,不打印 App Secret 和 token。 | + +### FS-2-003 接收人解析和消息构造 TDD + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 测试 / 服务 | +| 前置任务 | FS-2-002 | +| 涉及文件 | `tests/test_feishu_api_services.py` | +| 目标 | 测试指定个人接收人优先级、配置缺失、富文本消息摘要 | +| 开发步骤 | 1. 测试 `FEISHU_DEFAULT_USER_OPEN_ID` 优先;2. 测试无 open_id 时使用 `FEISHU_DEFAULT_USER_ID`;3. 测试均缺失返回 `recipient_missing`;4. 测试消息包含流程、批次、状态、摘要、链接和发起人 | +| 验收标准 | 测试先失败 | +| 验证命令 | `pytest tests/test_feishu_api_services.py -k 'recipient or message' -q` | +| Codex 执行提示 | 请先写接收人解析和富文本消息构造测试。 | + +### FS-2-004 实现接收人解析和消息构造 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 服务 | +| 前置任务 | FS-2-003 | +| 涉及文件 | `review_agent/notifications/recipient.py`、`review_agent/notifications/message_builder.py`、`review_agent/notifications/context.py` | +| 目标 | 生成统一通知上下文、指定个人接收人和飞书富文本 payload | +| 开发步骤 | 1. 定义 `NotificationContext`;2. 定义 `ResolvedFeishuTarget`;3. 实现 `resolve_configured_personal_recipient()`;4. 实现 `build_feishu_post_message()`;5. 实现 `build_message_summary()`;6. 链接使用 `PUBLIC_BASE_URL` | +| 验收标准 | 接收人和消息构造测试通过 | +| 验证命令 | `pytest tests/test_feishu_api_services.py -k 'recipient or message' -q` | +| Codex 执行提示 | 请实现通知上下文、接收人解析和飞书富文本消息构造。首期不需要群 @。 | + +### FS-2-005 消息 API client TDD 与实现 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 测试 | +| 前置任务 | FS-2-004 | +| 涉及文件 | `tests/test_feishu_api_services.py`、`review_agent/notifications/feishu_message_api.py` | +| 目标 | mock 飞书消息 API,完成成功、超时、错误码、token 失效重试一次 | +| 开发步骤 | 1. 写成功测试,断言请求携带 Authorization;2. 写非 0 code 测试;3. 写超时测试;4. 写 token 失效后刷新 token 并同步重试一次测试;5. 实现 `send_personal_message()` | +| 验收标准 | 消息 API client 测试通过 | +| 验证命令 | `pytest tests/test_feishu_api_services.py -q` | +| Codex 执行提示 | 请用 mock HTTP 实现飞书消息 API client。自动化测试不得请求真实飞书。 | + +### FS-2 阶段验证 + +```bash +pytest tests/test_feishu_api_services.py -q +``` + +--- + +## 八、FS-3 通知调度与记录 + +### FS-3-001 通知记录服务 TDD + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 测试 / 服务 | +| 前置任务 | FS-2 | +| 涉及文件 | `tests/test_feishu_notification_dispatcher.py` | +| 目标 | 先写通知调度、成功判重、失败允许再次发送、disabled 记录测试 | +| 开发步骤 | 1. 测试通知关闭写 disabled;2. 测试发送成功写 success;3. 测试已有 success 时不再调用 API;4. 测试已有 failed 时允许再次调用 API;5. 测试 token 失败写 failed | +| 验收标准 | 测试先失败 | +| 验证命令 | `pytest tests/test_feishu_notification_dispatcher.py -q` | +| Codex 执行提示 | 请先写统一通知调度测试,重点覆盖成功判重和失败可重试。 | + +### FS-3-002 实现通知记录和 dispatcher + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 服务 | +| 前置任务 | FS-3-001 | +| 涉及文件 | `review_agent/notifications/records.py`、`review_agent/notifications/dispatcher.py` | +| 目标 | 实现统一通知调度入口 | +| 开发步骤 | 1. 实现 `already_successfully_sent(dedupe_key)`;2. 实现 disabled、success、failed 记录创建;3. 实现 `dispatch_workflow_notification(context)`;4. 捕获服务异常并写 failed;5. 不让异常冒泡阻断工作流 | +| 验收标准 | dispatcher 测试通过 | +| 验证命令 | `pytest tests/test_feishu_notification_dispatcher.py -q` | +| Codex 执行提示 | 请实现统一通知调度和记录落库。注意 success 才判重,failed 不判重。 | + +### FS-3 阶段验证 + +```bash +pytest tests/test_feishu_notification_dispatcher.py -q +``` + +--- + +## 九、FS-4 三流程接入 + +### FS-4-001 工作流 adapter TDD + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 测试 / 集成 | +| 前置任务 | FS-3 | +| 涉及文件 | `tests/test_feishu_workflow_adapters.py` | +| 目标 | 测试自动汇总、法规核查、自动填表三类批次能生成正确通知上下文 | +| 开发步骤 | 1. 构造 `FileSummaryBatch` 和 items,断言文件摘要;2. 构造 `RegulatoryReviewBatch` 和 issues,断言风险摘要;3. 构造 `ApplicationFormFillBatch` 和 exports,断言导出/冲突摘要;4. 断言 result_url 使用 `PUBLIC_BASE_URL` | +| 验收标准 | 测试先失败 | +| 验证命令 | `pytest tests/test_feishu_workflow_adapters.py -q` | +| Codex 执行提示 | 请先写三个工作流 adapter 的测试。 | + +### FS-4-002 实现工作流 adapters + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 服务 | +| 前置任务 | FS-4-001 | +| 涉及文件 | `review_agent/notifications/workflow_adapters.py` | +| 目标 | 三个工作流批次转换为 `NotificationContext` | +| 开发步骤 | 1. 实现 `build_file_summary_context()`;2. 实现 `build_regulatory_review_context()`;3. 实现 `build_application_form_fill_context()`;4. 控制摘要长度;5. 处理 partial_success 和 failed | +| 验收标准 | adapter 测试通过 | +| 验证命令 | `pytest tests/test_feishu_workflow_adapters.py -q` | +| Codex 执行提示 | 请实现三个工作流通知上下文 adapter。 | + +### FS-4-003 接入三个工作流完成节点 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 工作流 | +| 前置任务 | FS-4-002 | +| 涉及文件 | `review_agent/file_summary/workflow.py`、`review_agent/regulatory_review/workflow.py`、`review_agent/application_form_fill/workflow.py` | +| 目标 | 三个工作流完成后调用通知 dispatcher | +| 开发步骤 | 1. 自动汇总成功/失败状态落定后触发通知;2. 法规核查报告和风险落库后触发通知;3. 自动填表 notify 节点改为统一通知服务;4. 捕获通知异常并记录非阻断错误;5. 保留现有 mock 测试兼容 | +| 验收标准 | 三流程通知集成测试通过 | +| 验证命令 | `pytest tests/test_file_summary_workflow.py tests/test_regulatory_notification.py tests/test_application_form_fill_notification.py` | +| Codex 执行提示 | 请把统一通知服务接入三个工作流完成节点,通知失败不得影响业务状态。 | + +### FS-4 阶段验证 + +```bash +pytest tests/test_feishu_workflow_adapters.py tests/test_file_summary_workflow.py tests/test_regulatory_notification.py tests/test_application_form_fill_notification.py +``` + +--- + +## 十、FS-5 页面展示 + +### FS-5-001 通知状态展示测试 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 测试 / 前端 | +| 前置任务 | FS-4 | +| 涉及文件 | `tests/test_file_summary_frontend.py`、`tests/test_regulatory_frontend.py`、`tests/test_application_form_fill_frontend.py` | +| 目标 | 测试三个流程页面或结果区域展示飞书通知状态 | +| 开发步骤 | 1. 准备 success 通知记录,断言页面出现“飞书通知已发送”;2. 准备 failed 记录,断言出现失败原因;3. 无记录时展示“暂无飞书通知记录”或不破坏页面 | +| 验收标准 | 测试先失败 | +| 验证命令 | `pytest tests/test_file_summary_frontend.py tests/test_regulatory_frontend.py tests/test_application_form_fill_frontend.py -k feishu` | +| Codex 执行提示 | 请先写三个流程通知状态展示测试。 | + +### FS-5-002 实现通知状态 presenter 和页面展示 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 前端 | +| 前置任务 | FS-5-001 | +| 涉及文件 | `review_agent/notifications/presenter.py`、`review_agent/*/views.py`、`templates/home.html` 或相关模板 | +| 目标 | 页面展示通知状态、接收人、发送时间、失败原因 | +| 开发步骤 | 1. 实现 `get_notification_records(workflow_type, batch_id)`;2. 在三个流程视图上下文中加入通知记录;3. 模板展示最近一条通知状态;4. 保持页面无记录时兼容 | +| 验收标准 | 页面展示测试通过 | +| 验证命令 | `pytest tests/test_file_summary_frontend.py tests/test_regulatory_frontend.py tests/test_application_form_fill_frontend.py -k feishu` | +| Codex 执行提示 | 请实现通知状态 presenter,并在三个流程结果页展示最近飞书通知状态。 | + +### FS-5 阶段验证 + +```bash +pytest tests/test_file_summary_frontend.py tests/test_regulatory_frontend.py tests/test_application_form_fill_frontend.py -k feishu +``` + +--- + +## 十一、FS-6 手动真实测试命令 + +### FS-6-001 测试命令 TDD + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 测试 / 命令 | +| 前置任务 | FS-5 | +| 涉及文件 | `tests/test_feishu_management_commands.py` | +| 目标 | 测试 management command 构造测试通知并调用 dispatcher | +| 开发步骤 | 1. mock dispatcher;2. 执行 `send_test_feishu_notification --username owner`;3. 断言构造测试上下文;4. 测试缺少用户时报错 | +| 验收标准 | 测试先失败 | +| 验证命令 | `pytest tests/test_feishu_management_commands.py -q` | +| Codex 执行提示 | 请先写真实飞书测试命令的自动化测试,dispatcher 要 mock。 | + +### FS-6-002 实现发送测试消息命令 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 运维 / 命令 | +| 前置任务 | FS-6-001 | +| 涉及文件 | `review_agent/management/commands/send_test_feishu_notification.py` | +| 目标 | 本地可手动向指定个人飞书账号发送真实测试消息 | +| 开发步骤 | 1. 支持 `--username`;2. 构造 workflow_type=`manual_test` 的 `NotificationContext`;3. 调用 dispatcher;4. 输出 send_status、target、error_message;5. 不打印 token 和 App Secret | +| 验收标准 | 命令测试通过;本地配置真实凭证后可手动验证 | +| 验证命令 | `pytest tests/test_feishu_management_commands.py -q`; `python manage.py send_test_feishu_notification --username owner` | +| Codex 执行提示 | 请实现发送测试飞书通知的 management command。自动测试 mock dispatcher,真实发送只手动运行。 | + +### FS-6 阶段验证 + +```bash +pytest tests/test_feishu_management_commands.py -q +``` + +手动验证命令: + +```bash +python manage.py send_test_feishu_notification --username owner +``` + +--- + +## 十二、FS-7 问答预留能力 + +### FS-7-001 批次摘要查询服务 TDD + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 测试 / 服务 | +| 前置任务 | FS-6 | +| 涉及文件 | `tests/test_feishu_question_reserved.py` | +| 目标 | 测试按批次号、latest、工作流类型查询三个流程摘要 | +| 开发步骤 | 1. 普通用户查询自己的最新法规核查批次;2. 普通用户不能查询他人批次;3. 管理员可查全部;4. 按批次号精确查询;5. 返回状态、摘要、链接 | +| 验收标准 | 测试先失败 | +| 验证命令 | `pytest tests/test_feishu_question_reserved.py -k query -q` | +| Codex 执行提示 | 请先写飞书问答预留的批次摘要查询测试。 | + +### FS-7-002 实现批次摘要查询服务 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 服务 | +| 前置任务 | FS-7-001 | +| 涉及文件 | `review_agent/feishu_questions/query.py`、`review_agent/feishu_questions/permissions.py` | +| 目标 | 支持三个工作流的摘要查询和权限过滤 | +| 开发步骤 | 1. 实现管理员/普通用户权限过滤;2. 实现 batch_no 查询;3. 实现 latest 查询;4. 实现 workflow_type 关键词映射;5. 返回统一摘要 dict | +| 验收标准 | 查询服务测试通过 | +| 验证命令 | `pytest tests/test_feishu_question_reserved.py -k query -q` | +| Codex 执行提示 | 请实现问答预留查询服务,普通用户只能查自己的批次,管理员可查全部。 | + +### FS-7-003 简单意图解析和日志 TDD + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 测试 / 服务 | +| 前置任务 | FS-7-002 | +| 涉及文件 | `tests/test_feishu_question_reserved.py` | +| 目标 | 测试规则解析“最新/最近/批次号/工作流关键词”,并记录问答日志 | +| 开发步骤 | 1. 识别 `RR-`、`AFF-`、`FS-` 批次号;2. 识别“最新/最近”;3. 识别“法规核查/自动填表/自动汇总”;4. 记录 `FeishuQuestionLog`,不保存完整回答正文 | +| 验收标准 | 测试先失败 | +| 验证命令 | `pytest tests/test_feishu_question_reserved.py -k 'intent or log' -q` | +| Codex 执行提示 | 请先写简单规则意图解析和问答日志测试,不接 LLM。 | + +### FS-7-004 实现意图解析、问答服务和模拟命令 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 后端 / 命令 | +| 前置任务 | FS-7-003 | +| 涉及文件 | `review_agent/feishu_questions/intent.py`、`review_agent/feishu_questions/service.py`、`review_agent/management/commands/feishu_question_simulate.py` | +| 目标 | 本地模拟飞书问答输入,返回批次摘要并记录日志 | +| 开发步骤 | 1. 实现 `parse_question_intent(text)`;2. 实现 `answer_question(user, text)`;3. 写入 `FeishuQuestionLog`;4. 实现命令 `python manage.py feishu_question_simulate --username owner "查最新法规核查"`;5. 输出回答摘要 | +| 验收标准 | 问答预留测试和命令测试通过 | +| 验证命令 | `pytest tests/test_feishu_question_reserved.py -q`; `python manage.py feishu_question_simulate --username owner "查最新法规核查"` | +| Codex 执行提示 | 请实现飞书问答预留的规则解析、服务和本地模拟命令。 | + +### FS-7 阶段验证 + +```bash +pytest tests/test_feishu_question_reserved.py -q +python manage.py feishu_question_simulate --username owner "查最新法规核查" +``` + +--- + +## 十三、FS-8 文档与全量回归 + +### FS-8-001 更新配置说明 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 文档 / 配置 | +| 前置任务 | FS-7 | +| 涉及文件 | `README.md`、`.env.example` 或项目配置说明文档 | +| 目标 | 说明飞书相关环境变量和手动测试命令 | +| 开发步骤 | 1. 写明变量名和用途;2. 标注不要提交真实 App Secret;3. 写明 `send_test_feishu_notification` 用法;4. 写明自动化测试不请求真实飞书 | +| 验收标准 | 配置说明清楚,无真实密钥 | +| 验证命令 | 手动检查文档 | +| Codex 执行提示 | 请补充飞书配置说明,只写变量名和用途,不写真实值。 | + +### FS-8-002 全量相关测试 + +| 项目 | 内容 | +| --- | --- | +| 任务类型 | 测试 / 回归 | +| 前置任务 | FS-8-001 | +| 涉及文件 | 无固定文件 | +| 目标 | 运行飞书新增测试和三个工作流关键回归 | +| 开发步骤 | 1. 运行 Django check;2. 运行飞书新增测试;3. 运行三个工作流关键测试;4. 修复与本功能相关失败;5. 记录无法处理的既有失败 | +| 验收标准 | 新增测试通过,关键回归通过 | +| 验证命令 | `python manage.py check`; `pytest tests/test_feishu_*.py tests/test_file_summary_workflow.py tests/test_regulatory_notification.py tests/test_application_form_fill_notification.py` | +| Codex 执行提示 | 请运行飞书新增测试和三个工作流关键回归,确保首期飞书接入不破坏既有功能。 | + +### FS-8 阶段验证 + +```bash +python manage.py check +pytest tests/test_feishu_*.py tests/test_file_summary_workflow.py tests/test_regulatory_notification.py tests/test_application_form_fill_notification.py +``` + +--- + +## 十四、建议提交切分 + +| 提交 | 建议提交信息 | 包含内容 | +| --- | --- | --- | +| 1 | `feat: add feishu notification data models` | 模型、迁移、Admin、配置项 | +| 2 | `feat: add feishu api notification services` | token、接收人、消息构造、消息 API client | +| 3 | `feat: add workflow notification dispatcher` | dispatcher、记录判重、三流程 adapter | +| 4 | `feat: wire feishu notifications into workflows` | 三个工作流接入 | +| 5 | `feat: show feishu notification status` | 页面展示 | +| 6 | `feat: add feishu notification test command` | 真实发送测试命令 | +| 7 | `feat: add feishu question preview services` | 问答预留查询、解析、日志、模拟命令 | +| 8 | `docs: document feishu configuration` | 配置说明和回归修正 | + +--- + +## 十五、风险与处理策略 + +| 风险 | 影响 | 策略 | +| --- | --- | --- | +| 飞书应用权限不足 | 消息 API 返回无权限 | 手动测试命令先验证;错误码入库展示 | +| open_id/user_id 不正确 | 个人账号收不到消息 | 接收人配置缺失或错误时记录 failed,命令输出错误 | +| token 缓存过期处理不当 | 偶发发送失败 | token 失效时刷新并允许消息 API 同步重试一次 | +| 三流程状态差异 | 通知触发点不一致 | 用 adapter 隔离各流程摘要生成 | +| 页面展示影响既有模板 | 前端回归失败 | 使用小型通知状态区块,无记录时不改变主流程展示 | +| 问答预留过度设计 | 影响首期交付 | 只做规则解析和摘要查询,不接事件订阅、不接 LLM | diff --git a/docs/5.开发计划/5.第1章监管信息材料包生成.md b/docs/5.开发计划/5.第1章监管信息材料包生成.md new file mode 100644 index 0000000..88e071d --- /dev/null +++ b/docs/5.开发计划/5.第1章监管信息材料包生成.md @@ -0,0 +1,622 @@ +# 第1章监管信息材料包生成开发计划 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/1.需求分析/5.第1章监管信息材料包生成.md | +| 功能设计文档 | docs/2.功能设计/5.第1章监管信息材料包生成.md | +| 数据库设计文档 | docs/3.数据库设计/5.第1章监管信息材料包生成.md | +| 详细设计文档 | docs/4.详细设计/5.第1章监管信息材料包生成.md | +| 参考开发计划 | docs/5.开发计划/3.产品关键信息提取与申报文件自动填表.md | +| 功能名称 | 第1章监管信息材料包生成 | +| 工作流编码 | regulatory_info_package | +| 批次号规则 | RIP-YYYYMMDDHHMMSS-abcdef | +| 计划日期 | 2026-06-10 | +| 计划版本 | V1.0 | + +--- + +## 一、开发计划目标 + +本开发计划面向 Codex 执行,目标是把 `regulatory_info_package` 独立工作流按可验证、可回滚、可阶段验收的方式落地。计划以现有自动填表工作流 `application_form_fill` 为主要参考,但保持独立模块、独立批次、独立产物、独立通知和独立前端卡片。 + +现状裁决:当前最新代码中尚未存在 `regulatory_info_package` 正式工作流,本计划按“新建正式材料包工作流”执行;不得把该功能并入或改造 `application_form_fill`。 + +开发完成后,用户可在对话中上传或指定产品说明书,并通过“根据说明书生成第1章监管信息”触发工作流。系统基于 `docs/0.原始材料/第1章 监管信息` 样例模板生成 7 个监管信息文件,以 `第1章 监管信息(预生成版).zip` 作为首位下载入口,同时提供单文件和追溯 Excel 辅助下载。 + +--- + +## 二、已确认开发规则 + +| 规则 | 内容 | +| --- | --- | +| 工作流独立 | 新增 `workflow_type=regulatory_info_package`,不并入 `application_form_fill` | +| 模块独立 | 新增 `review_agent/regulatory_info_package/`,服务与自动填表平级 | +| 模型集中 | Django 模型继续放在 `review_agent/models.py` | +| 节点幂等 | RIP 节点必须基于 `workflow_type + workflow_batch_id + node_code` 做幂等创建或数据库唯一约束 | +| 单说明书输入 | 用户消息指定文件名优先,其次 active 附件,再兼容最近成功文件汇总 | +| 多候选处理 | 不做选择弹窗,通过对话反问用户确认说明书文件名 | +| 模板固定 | 固定处理第1章监管信息 7 个模板 | +| 模板字段化 | 优先把模板整理为 Agent/代码可识别的字段模板,使用内容控件 Tag 或稳定占位符;代码只填字段,不依赖手工改格式 | +| 抽取策略 | 规则抽取和 LLM 抽取并行,LLM 最多重试 3 次,失败后规则结果继续 | +| 文档生成 | 工作流节点串行,`generate_docs` 节点内部每个文档独立线程处理 | +| `.doc` 策略 | CH1.9 能力驱动:探测到 Word COM/UNO 时优先原生 `.doc`,无原生能力时明确记录并允许 `.docx` 兜底 | +| zip 策略 | zip 只包含成功或兜底成功文件,失败文件不进入 zip | +| 高亮策略 | 缺失项 `/` 黄底;LLM-only 黄底;冲突黄底红字 | +| 追溯策略 | 用户下载 Excel;JSON 只写后台 logs 目录 | +| 前端策略 | 只做最小接入,不单独建设新页面或独立样式体系 | +| TDD | 新行为先写失败测试,再实现 | +| Git 提交 | 每阶段验证通过后生成提交摘要;是否本地提交由用户确认 | +| 用户变更保护 | 不回滚、不覆盖用户已有未提交变更 | + +--- + +## 三、规范依据与裁决 + +| 规范来源 | 命中内容 | 本计划裁决 | +| --- | --- | --- | +| GYRX 后端开发规范 | 接口响应、日志、增量规范 | 状态接口、下载权限、异常降级和日志留痕按现有 Django 模式实现 | +| GYRX 前端开发规范 | 样式复用、组件接入、下载图标建议 | 复用现有对话页和工作流卡片样式,必要时只补少量语义化样式 | +| 既有自动填表开发计划 | 阶段拆分、测试先行、每阶段验证 | 本计划沿用阶段结构和 Codex 执行提示粒度 | +| 第1章监管信息详细设计 | 独立模块、7 模板、doc 兜底、zip 首位 | 作为本计划最高优先级依据 | + +未发现规范冲突。项目专项设计优先于通用规范。 + +--- + +## 四、总体验收标准 + +| 类别 | 完成标准 | +| --- | --- | +| 触发 | 固定提示词和 LLM 路由均可触发 `regulatory_info_package` | +| 输入选择 | 能按用户指定文件名、active 附件、最近文件汇总选择说明书;多候选可反问 | +| 批次 | 能创建 `RegulatoryInfoPackageBatch`,节点和事件可查询 | +| 模板 | 能加载并校验 7 个模板配置,模板复制只写批次目录 | +| 抽取 | 规则抽取可独立跑通,LLM 失败不阻断主链路 | +| 合并 | missing、llm_only、conflict 均有可追溯结构和高亮决策 | +| docx 生成 | 6 个 `.docx` 文件能按模板生成并保留基本版式 | +| doc 处理 | CH1.9 优先 `.doc` 原生处理,失败时 `.docx` 兜底,状态可见 | +| ZIP | `第1章 监管信息(预生成版).zip` 排在助手回显首位,只包含成功/兜底成功文件 | +| 单文件 | 成功文件有辅助下载,失败文件显示原因且无下载链接 | +| 追溯 | 用户可下载 `traceability.xlsx`,JSON 写入 `logs/` | +| 前端 | 对话快捷入口、工作流卡片、状态轮询和下载列表正常 | +| 权限 | 非批次所属用户不能下载 RIP 产物 | +| 回归 | `python manage.py check` 和相关 pytest 通过,既有文件汇总/自动填表/法规核查不回归 | + +--- + +## 五、阶段总览 + +| 阶段 | 名称 | 目标 | 阶段验收 | +| --- | --- | --- | --- | +| RIP-0 | 准备与基线回归 | 创建开发分支,确认依赖和既有测试状态 | 基线命令结果已记录 | +| RIP-1 | 数据模型与导出扩展 | 新增三张模型,扩展 zip 下载能力 | migration、模型和下载权限测试通过 | +| RIP-2 | 模块骨架与模板配置 | 新建模块、schema、YAML 配置和存储服务 | 配置加载和路径安全测试通过 | +| RIP-3 | 触发与工作流骨架 | 接入路由、批次创建、节点流转和状态接口 | 可创建并运行空工作流 | +| RIP-4 | 输入选择与说明书解析 | 选择说明书,解析 docx 段落、章节和表格 | 输入选择和说明书解析测试通过 | +| RIP-5 | 字段抽取与合并 | 规则 + LLM 并行抽取、重试、合并和高亮决策 | 抽取、重试、冲突合并测试通过 | +| RIP-6 | DOCX 文档生成 | 实现 6 个 docx 模板生成、产品列表重建和高亮 | docx 生成和 XML 高亮测试通过 | +| RIP-7 | CH1.9 DOC 适配 | 实现 `.doc` 原生适配探测和 `.docx` 兜底 | doc 兜底、失败隔离测试通过 | +| RIP-8 | 追溯、ZIP 与下载权限 | 生成 Excel、logs JSON、ZIP 和导出记录 | ZIP 内容、追溯、权限测试通过 | +| RIP-9 | 摘要、通知与状态归并 | 生成助手摘要,写通知记录,落定批次状态 | partial_success 等状态测试通过 | +| RIP-10 | 前端接入与总体验收 | 接入快捷入口、卡片、状态轮询和下载展示 | 前端回归和全量后端测试通过 | + +--- + +## 六、RIP-0 准备与基线回归 + +### RIP-0-001 创建开发分支并确认工作区 + +| 项 | 内容 | +| --- | --- | +| 目标 | 创建本功能开发分支,确认当前工作区已有变更 | +| 修改范围 | Git 分支,不修改业务代码 | +| 验收标准 | 分支名符合 `codex/` 前缀;记录已有未提交变更,不回滚用户变更 | +| Codex 执行提示 | 请创建 `codex/regulatory-info-package` 开发分支,运行 `git status --short`,确认设计文档和目录重排状态,不要回滚无关变更。 | + +### RIP-0-002 确认依赖与基线测试 + +| 项 | 内容 | +| --- | --- | +| 目标 | 确认 Django、python-docx、openpyxl、PyYAML、可选 Word COM 环境状态 | +| 修改范围 | 不修改业务代码 | +| 验收标准 | `python manage.py check` 可执行;关键依赖可 import;既有失败需记录 | +| Codex 执行提示 | 请运行 Django check 和关键回归测试,确认依赖可用。若发现既有失败,只记录并继续按计划隔离,不改无关代码。 | + +### RIP-0 阶段验证 + +```bash +python manage.py check +pytest tests/test_file_summary_views.py -k download +``` + +--- + +## 七、RIP-1 数据模型与导出扩展 + +### RIP-1-001 新增监管信息材料包 ORM 模型 + +| 项 | 内容 | +| --- | --- | +| 目标 | 新增 `RegulatoryInfoPackageBatch`、`RegulatoryInfoPackageArtifact`、`RegulatoryInfoPackageNotificationRecord` | +| 修改范围 | `review_agent/models.py` | +| 验收标准 | 字段、枚举、索引、软删除、关联关系符合数据库设计 | +| Codex 执行提示 | 请按 `docs/3.数据库设计/5.第1章监管信息材料包生成.md` 新增三张模型,模型集中放在 `review_agent/models.py`,不要新增字段级数据库表。 | + +### RIP-1-002 扩展导出类型和下载 MIME + +| 项 | 内容 | +| --- | --- | +| 目标 | `ExportedSummaryFile.ExportType` 增加 `zip`,下载 MIME 支持 `.zip`、`.doc`、`.docx` | +| 修改范围 | `review_agent/models.py`、`review_agent/file_summary/views.py` | +| 验收标准 | zip 可下载;doc/docx MIME 正确;原有导出不回归 | +| Codex 执行提示 | 请扩展 `ExportedSummaryFile` 导出类型,并在下载接口按 workflow_type 和文件后缀处理权限与 content type。 | + +### RIP-1-003 生成迁移并补模型测试 + +| 项 | 内容 | +| --- | --- | +| 目标 | 生成数据库迁移并覆盖基础模型行为 | +| 修改范围 | `review_agent/migrations/`、`tests/` | +| 验收标准 | migration 可应用;模型测试覆盖批次号、状态、artifact、通知、zip export type | +| Codex 执行提示 | 请生成迁移并新增 `tests/test_regulatory_info_package_models.py`,优先覆盖模型字段默认值、导出类型,以及 `WorkflowNodeRun` 在 RIP 批次下的幂等/唯一节点创建。 | + +### RIP-1 阶段验证 + +```bash +python manage.py check +pytest tests/test_regulatory_info_package_models.py tests/test_file_summary_views.py -k download +``` + +--- + +## 八、RIP-2 模块骨架与模板配置 + +### RIP-2-001 创建 regulatory_info_package 模块骨架 + +| 项 | 内容 | +| --- | --- | +| 目标 | 新增独立模块目录和基础文件 | +| 修改范围 | `review_agent/regulatory_info_package/` | +| 验收标准 | 模块可 import;不影响现有 `application_form_fill` | +| Codex 执行提示 | 请创建详细设计中的模块骨架,先放常量、schema、storage、events、workflow 空实现和 service 包,不提前写复杂业务。 | + +### RIP-2-002 编写模板配置 YAML + +| 项 | 内容 | +| --- | --- | +| 目标 | 配置 7 个样例模板、输出文件名、策略、字段 Tag/占位符映射和 `.doc` 标记 | +| 修改范围 | `review_agent/regulatory_info_package/templates/regulatory_info_package_templates_v1.yaml` | +| 验收标准 | 7 个模板完整;zip 名称为 `第1章 监管信息(预生成版).zip`;字段映射优先使用内容控件 Tag 或稳定占位符 | +| Codex 执行提示 | 请按详细设计录入模板配置,source_dir 指向样例目录,字段 targets 优先写 content_control_tag 或 placeholder;CH1.9 声明 `prefer_legacy_doc_native: true` 且允许 docx fallback。 | + +### RIP-2-003 实现配置加载、模板仓库和存储目录 + +| 项 | 内容 | +| --- | --- | +| 目标 | 实现 YAML 加载校验、模板复制、批次目录创建、路径安全检查 | +| 修改范围 | `template_config.py`、`template_repository.py`、`storage.py` | +| 验收标准 | 配置错误可返回清晰错误;模板只复制到批次目录;不写原始材料目录;能审计模板是否包含所需 Tag/占位符 | +| Codex 执行提示 | 请实现配置加载、模板复制和模板字段审计服务,所有路径必须校验位于批次工作目录内,原始模板目录只读。 | + +### RIP-2-004 模板字段化整理与审计 + +| 项 | 内容 | +| --- | --- | +| 目标 | 将样例模板升级为代码友好的字段模板,不手工改生成文件格式 | +| 修改范围 | `docs/0.原始材料/第1章 监管信息` 的模板副本或 `review_agent/regulatory_info_package/templates/field_manifest.yaml` | +| 验收标准 | CH1.4 关键字段、复选框、声明类产品名/申请人位置有稳定 Tag 或占位符;审计缺失字段时测试失败 | +| Codex 执行提示 | 请优先使用 Word 内容控件 Tag;若暂不具备内容控件编辑能力,则使用不会影响版式的稳定占位符,并在配置中记录字段与目标位置。 | + +### RIP-2 阶段验证 + +```bash +python manage.py check +pytest tests/test_regulatory_info_package_template_config.py +``` + +--- + +## 九、RIP-3 触发与工作流骨架 + +### RIP-3-001 扩展意图路由 + +| 项 | 内容 | +| --- | --- | +| 目标 | 新增 `regulatory_info_package` action,支持固定关键词和 LLM 路由 | +| 修改范围 | `review_agent/skill_router.py` | +| 验收标准 | 固定提示词直接命中;LLM action 列表包含本工作流;原路由不回归 | +| Codex 执行提示 | 请扩展意图路由,新增 `starts_regulatory_info_package` 标记,避免破坏 file_summary、regulatory_review 和 application_form_fill。 | + +### RIP-3-002 实现批次创建和节点初始化 + +| 项 | 内容 | +| --- | --- | +| 目标 | 创建批次、生成节点、记录事件 | +| 修改范围 | `workflow.py`、`events.py`、`constants.py` | +| 验收标准 | 可创建 `RIP-...` 批次;节点按定义初始化;事件可查询 | +| Codex 执行提示 | 请实现批次创建和节点初始化,workflow_type 必须写 `regulatory_info_package`。 | + +### RIP-3-003 实现执行器骨架和状态接口 + +| 项 | 内容 | +| --- | --- | +| 目标 | 工作流节点可完整流转,status 接口可返回批次、节点、导出和风险信息 | +| 修改范围 | `workflow.py`、`views.py`、`urls.py` 或现有 URL 注册文件 | +| 验收标准 | 空工作流可从 pending 到 completed;状态接口校验用户权限 | +| Codex 执行提示 | 请先实现可运行的空工作流骨架,业务节点可以临时 no-op,但状态流转和权限必须真实。 | + +### RIP-3-004 接入对话启动逻辑 + +| 项 | 内容 | +| --- | --- | +| 目标 | `stream_message` 能启动本工作流或返回说明书反问 | +| 修改范围 | `review_agent/services.py` | +| 验收标准 | 触发后发送 `workflow_started`;无输入或多候选时不误启动 | +| Codex 执行提示 | 请在 `stream_message` 增加 regulatory_info_package 分支,先调用输入选择服务,再决定启动、提示上传或反问。 | + +### RIP-3 阶段验证 + +```bash +python manage.py check +pytest tests/test_regulatory_info_package_trigger.py tests/test_regulatory_info_package_workflow.py tests/test_regulatory_info_package_views.py +``` + +--- + +## 十、RIP-4 输入选择与说明书解析 + +### RIP-4-001 实现说明书输入选择 + +| 项 | 内容 | +| --- | --- | +| 目标 | 按用户消息、active 附件、最近汇总批次选择说明书 | +| 修改范围 | `services/input_select.py` | +| 验收标准 | 文件名模糊匹配、唯一 docx、多个说明书、无说明书均有明确结果 | +| Codex 执行提示 | 请实现 `select_instruction_input`,多候选返回 waiting_user 语义,由对话反问用户确认具体文件名。 | + +### RIP-4-002 实现说明书 docx 解析 + +| 项 | 内容 | +| --- | --- | +| 目标 | 读取说明书段落、章节、表格、组成成分表和 front_text | +| 修改范围 | `services/instruction_extract.py` | +| 验收标准 | 能解析 `目标产品说明书.docx` 的产品名称、章节和主要表格结构 | +| Codex 执行提示 | 请使用结构化 Word 解析能力,不用脆弱的纯字符串拼接;解析结果写入可序列化 schema。 | + +### RIP-4-003 写入说明书抽取日志产物 + +| 项 | 内容 | +| --- | --- | +| 目标 | 保存 `logs/instruction_extract.json` 并创建 artifact | +| 修改范围 | `workflow.py`、`storage.py`、`instruction_extract.py` | +| 验收标准 | JSON 只在后台 logs 目录,不进入用户下载列表 | +| Codex 执行提示 | 请在 text_extract 节点保存说明书抽取 JSON,artifact 可记录,但不要创建 ExportedSummaryFile。 | + +### RIP-4 阶段验证 + +```bash +pytest tests/test_regulatory_info_package_input_select.py tests/test_regulatory_info_package_instruction_extract.py +``` + +--- + +## 十一、RIP-5 字段抽取与合并 + +### RIP-5-001 实现规则字段抽取 + +| 项 | 内容 | +| --- | --- | +| 目标 | 从说明书章节和表格中抽取产品名称、包装规格、预期用途、组成、储存条件、样本类型、适用仪器、标准号等 | +| 修改范围 | `services/field_extract.py` | +| 验收标准 | 不依赖 LLM 时可抽取关键字段并支撑 demo | +| Codex 执行提示 | 请优先实现规则抽取,抽取结果包含 value、evidence、confidence 和 source。 | + +### RIP-5-002 实现 LLM 抽取封装和三次重试 + +| 项 | 内容 | +| --- | --- | +| 目标 | LLM 结构化抽取,失败最多重试 3 次,失败后不阻断 | +| 修改范围 | `services/field_extract.py`、`prompts/field_extract.md` | +| 验收标准 | 0s/1s/2s 重试;解析失败可记录错误;规则结果继续 | +| Codex 执行提示 | 请封装 LLM 调用为可 mock 的函数,测试中不要真实调用外部模型。 | + +### RIP-5-003 实现规则与 LLM 并行抽取 + +| 项 | 内容 | +| --- | --- | +| 目标 | 使用线程并行执行规则抽取和 LLM 抽取 | +| 修改范围 | `services/field_extract.py` | +| 验收标准 | 任一分支失败不影响另一分支结果;输出 `field_extract_result.json` | +| Codex 执行提示 | 请使用 `ThreadPoolExecutor(max_workers=2)`,不要在子线程直接写数据库。 | + +### RIP-5-004 实现字段合并和高亮决策 + +| 项 | 内容 | +| --- | --- | +| 目标 | 输出 missing、llm_only、conflict 和最终写入值 | +| 修改范围 | `services/field_merge.py` | +| 验收标准 | 全缺失写 `/` 黄底;LLM-only 黄底;冲突黄底红字;合并结果可追溯 | +| Codex 执行提示 | 请实现 `MergedField` 结构,合并结果写 `logs/merged_fields.json`,并同步批次摘要字段。 | + +### RIP-5 阶段验证 + +```bash +pytest tests/test_regulatory_info_package_field_extract.py tests/test_regulatory_info_package_field_merge.py +``` + +--- + +## 十二、RIP-6 DOCX 文档生成 + +### RIP-6-001 实现 DocxDocumentAdapter + +| 项 | 内容 | +| --- | --- | +| 目标 | 支持段落/表格替换、表格单元格填充、黄色底色、红字 | +| 修改范围 | `services/document_writer.py`、`services/docx_document.py` | +| 验收标准 | 可处理 run 拆分;测试可检查 docx XML 高亮和红字 | +| Codex 执行提示 | 请优先支持本模板需要的替换和表格填充场景,复杂通用 Word 引擎不要过度设计。 | + +### RIP-6-002 实现 6 个 DOCX 文件生成策略 + +| 项 | 内容 | +| --- | --- | +| 目标 | 生成 CH1.2、CH1.4、CH1.5、CH1.11.1、CH1.11.5、CH1.11.6 | +| 修改范围 | `services/package_generate.py`、`services/standard_candidates.py` | +| 验收标准 | 6 个 docx 文件可生成;缺失/LLM-only/冲突样式正确 | +| Codex 执行提示 | 请先完成 docx 主链路。CH1.5 产品列表必须转成样例表头:包装规格、货号、组成、组分、主要组成成分、规格/数量,其中货号 `/` 黄底。 | + +### RIP-6-003 实现 generate_docs 内部并发 + +| 项 | 内容 | +| --- | --- | +| 目标 | 每个文档独立线程生成,主线程统一写 artifact/export | +| 修改范围 | `services/package_generate.py`、`workflow.py` | +| 验收标准 | 单个文件失败不影响其他文件;返回 `GeneratedFileResult` 列表 | +| Codex 执行提示 | 请使用独立模板副本,子线程不要写数据库;所有异常转成文件级 failed 状态。 | + +### RIP-6 阶段验证 + +```bash +pytest tests/test_regulatory_info_package_docx_writer.py tests/test_regulatory_info_package_package_generate.py +``` + +--- + +## 十三、RIP-7 CH1.9 DOC 适配 + +### RIP-7-001 实现 LegacyDocDocumentAdapter 能力探测 + +| 项 | 内容 | +| --- | --- | +| 目标 | 探测 Word COM、LibreOffice UNO 或可用兜底能力 | +| 修改范围 | `services/legacy_doc_document.py` | +| 验收标准 | 当前环境无原生能力时返回清晰 capability,不崩溃;测试不要求本机必须安装 Word 或 LibreOffice | +| Codex 执行提示 | 请先实现能力探测和接口骨架,Windows Word COM/LibreOffice UNO 可作为原生能力;不可用时明确进入 docx 兜底。 | + +### RIP-7-002 实现 CH1.9 原生写入与 docx 兜底 + +| 项 | 内容 | +| --- | --- | +| 目标 | CH1.9 优先 `.doc` 输出,失败时生成同语义 `.docx` | +| 修改范围 | `legacy_doc_document.py`、`package_generate.py` | +| 验收标准 | 有原生能力时原生成功状态 success;无原生能力或原生失败但兜底成功时状态 fallback_success;两者失败不进入 zip | +| Codex 执行提示 | 请把能力探测、原生失败和兜底失败都写入 `adapter_summary` 和 `risk_notes`,不要静默转换。 | + +### RIP-7-003 补充 doc 适配器测试 + +| 项 | 内容 | +| --- | --- | +| 目标 | 覆盖 capability、兜底成功、失败隔离 | +| 修改范围 | `tests/test_regulatory_info_package_legacy_doc.py` | +| 验收标准 | 测试不依赖本机必须安装 Word;用 mock 覆盖原生成功/失败 | +| Codex 执行提示 | 请用 mock 模拟 Word COM 可用和不可用场景,保证 CI 或本地无 Word 时测试仍稳定。 | + +### RIP-7 阶段验证 + +```bash +pytest tests/test_regulatory_info_package_legacy_doc.py tests/test_regulatory_info_package_package_generate.py +``` + +--- + +## 十四、RIP-8 追溯、ZIP 与下载权限 + +### RIP-8-001 实现追溯 Excel 和后台 JSON + +| 项 | 内容 | +| --- | --- | +| 目标 | 生成 `exports/traceability.xlsx` 和 `logs/traceability.json` | +| 修改范围 | `services/traceability_export.py` | +| 验收标准 | Excel 可下载;JSON 不进入用户下载列表 | +| Codex 执行提示 | 请用 openpyxl 生成 Excel,字段包含 target_file、target_field、final_value、extraction_source、evidence、highlight_reason、needs_review。 | + +### RIP-8-002 实现 zip 打包 + +| 项 | 内容 | +| --- | --- | +| 目标 | 生成 `第1章 监管信息(预生成版).zip` | +| 修改范围 | `services/zip_export.py` | +| 验收标准 | zip 只包含 success/fallback_success 文件;失败文件不入包 | +| Codex 执行提示 | 请用 Python 标准库 `zipfile` 打包,zip 中保留最终输出文件名。CH1.9 兜底成功时放入 `.docx` 文件。 | + +### RIP-8-003 创建导出记录和下载权限 + +| 项 | 内容 | +| --- | --- | +| 目标 | zip、单文件、Excel 均写 `ExportedSummaryFile`;下载接口校验用户权限 | +| 修改范围 | `file_summary/views.py`、`storage.py`、`zip_export.py` | +| 验收标准 | 非批次用户不能下载;zip 在 exports 返回顺序中排首位 | +| Codex 执行提示 | 请按 `workflow_type=regulatory_info_package` 反查批次所属 conversation/user,软删除批次不可下载。 | + +### RIP-8 阶段验证 + +```bash +pytest tests/test_regulatory_info_package_traceability.py tests/test_regulatory_info_package_zip.py tests/test_regulatory_info_package_views.py +``` + +--- + +## 十五、RIP-9 摘要、通知与状态归并 + +### RIP-9-001 实现助手 Markdown 摘要 + +| 项 | 内容 | +| --- | --- | +| 目标 | 完成后返回 zip 首位、单文件列表、失败原因、待确认摘要 | +| 修改范围 | `services/summary.py`、`workflow.py` | +| 验收标准 | zip 链接在回复首位;失败文件显示原因且无下载;待确认数量准确 | +| Codex 执行提示 | 请严格按详细设计生成助手摘要,partial_success 时也要展示可下载 zip 和失败文件原因。 | + +### RIP-9-002 实现通知记录和统一通知接入 + +| 项 | 内容 | +| --- | --- | +| 目标 | 写 `RegulatoryInfoPackageNotificationRecord`,调用统一通知服务 | +| 修改范围 | `services/notifier.py`、`workflow.py` | +| 验收标准 | 通知失败不阻断下载;失败写 `risk_notes` | +| Codex 执行提示 | 请复用已有通知模式,先保证本地测试可 mock;不要让外部通知失败影响批次主状态。 | + +### RIP-9-003 完成状态归并 + +| 项 | 内容 | +| --- | --- | +| 目标 | 根据生成结果、zip、追溯、通知落定 success/partial_success/failed/waiting_user | +| 修改范围 | `workflow.py` | +| 验收标准 | 7 文件成功为 success;部分文件失败但有 zip 为 partial_success;全部失败为 failed | +| Codex 执行提示 | 请把状态归并集中在一个函数,测试覆盖 docx 兜底、zip 失败、通知失败、产品名缺失。 | + +### RIP-9 阶段验证 + +```bash +pytest tests/test_regulatory_info_package_workflow.py tests/test_regulatory_info_package_notification.py +``` + +--- + +## 十六、RIP-10 前端接入与总体验收 + +### RIP-10-001 增加对话快捷入口 + +| 项 | 内容 | +| --- | --- | +| 目标 | 对话框底部增加“第1章监管信息”快捷提示 | +| 修改范围 | `templates/home.html` | +| 验收标准 | 点击后填入或发送 `根据说明书生成第1章监管信息` | +| Codex 执行提示 | 请复用现有 tool-chip 样式,不单独创建新前端样式文件,除非现有结构无法展示。 | + +### RIP-10-002 工作流卡片和状态轮询支持 + +| 项 | 内容 | +| --- | --- | +| 目标 | 前端识别 `regulatory_info_package`,使用新 status URL 轮询 | +| 修改范围 | `static/js/app.js`、`templates/home.html` | +| 验收标准 | 卡片能展示节点、状态、风险和导出列表;终态识别 success/partial_success/failed/waiting_user | +| Codex 执行提示 | 请在现有工作流卡片逻辑中增量接入,不复制一套新卡片实现。 | + +### RIP-10-003 下载展示和失败文件展示 + +| 项 | 内容 | +| --- | --- | +| 目标 | zip 首位展示,单文件辅助下载,失败文件展示原因 | +| 修改范围 | `static/js/app.js` | +| 验收标准 | exports 返回顺序被保留;失败文件无下载按钮;traceability.xlsx 可下载 | +| Codex 执行提示 | 请以后端 exports 顺序为准,不新增 `is_primary` 字段;zip 已由后端排首位。 | + +### RIP-10-004 总体验收与回归 + +| 项 | 内容 | +| --- | --- | +| 目标 | 全链路验证和回归保护 | +| 修改范围 | 测试、必要的 bug fix | +| 验收标准 | Django check、RIP 测试、关键既有测试通过;能用样例说明书生成材料包 | +| Codex 执行提示 | 请用 `docs/0.原始材料/目标产品说明书.docx` 做端到端验证,确认 zip、单文件、Excel、logs 和摘要均符合设计。 | + +### RIP-10 阶段验证 + +```bash +python manage.py check +pytest tests/test_regulatory_info_package_frontend.py +pytest tests/test_regulatory_info_package_models.py tests/test_regulatory_info_package_trigger.py tests/test_regulatory_info_package_input_select.py tests/test_regulatory_info_package_template_config.py tests/test_regulatory_info_package_instruction_extract.py tests/test_regulatory_info_package_field_extract.py tests/test_regulatory_info_package_field_merge.py tests/test_regulatory_info_package_docx_writer.py tests/test_regulatory_info_package_legacy_doc.py tests/test_regulatory_info_package_package_generate.py tests/test_regulatory_info_package_traceability.py tests/test_regulatory_info_package_zip.py tests/test_regulatory_info_package_workflow.py tests/test_regulatory_info_package_views.py tests/test_regulatory_info_package_notification.py +``` + +--- + +## 十七、测试分层要求 + +| 测试层 | 覆盖内容 | 建议文件 | +| --- | --- | --- | +| 模型测试 | 批次、产物、通知、zip 导出类型 | `tests/test_regulatory_info_package_models.py` | +| 路由测试 | 固定关键词、LLM action、对话启动分支 | `tests/test_regulatory_info_package_trigger.py` | +| 输入测试 | 文件名匹配、active 附件、多候选反问 | `tests/test_regulatory_info_package_input_select.py` | +| 配置测试 | YAML 加载、模板缺失、code 唯一 | `tests/test_regulatory_info_package_template_config.py` | +| 解析测试 | 说明书章节、表格、组成成分表 | `tests/test_regulatory_info_package_instruction_extract.py` | +| 抽取测试 | 规则抽取、LLM 重试、失败降级 | `tests/test_regulatory_info_package_field_extract.py` | +| 合并测试 | missing、llm_only、conflict | `tests/test_regulatory_info_package_field_merge.py` | +| 文档测试 | docx 替换、表格、高亮、红字 | `tests/test_regulatory_info_package_docx_writer.py` | +| doc 测试 | adapter 探测、docx 兜底、失败状态 | `tests/test_regulatory_info_package_legacy_doc.py` | +| 生成测试 | 7 文件并发生成、异常隔离 | `tests/test_regulatory_info_package_package_generate.py` | +| 追溯测试 | Excel 下载、logs JSON | `tests/test_regulatory_info_package_traceability.py` | +| zip 测试 | 只打包 success/fallback_success | `tests/test_regulatory_info_package_zip.py` | +| 工作流测试 | 节点流转、状态归并、partial_success | `tests/test_regulatory_info_package_workflow.py` | +| 接口测试 | start/status/download 权限 | `tests/test_regulatory_info_package_views.py` | +| 通知测试 | 通知记录、通知失败降级 | `tests/test_regulatory_info_package_notification.py` | +| 前端测试 | chip、卡片、状态 URL、下载展示 | `tests/test_regulatory_info_package_frontend.py` | + +--- + +## 十八、Codex 自动化执行规则 + +| 规则 | 内容 | +| --- | --- | +| 顺序执行 | 必须从 RIP-0 到 RIP-10 顺序执行,不得跳阶段 | +| 阶段聚焦 | 当前阶段失败时先修复当前阶段,不继续后续阶段 | +| TDD | 新行为先写失败测试,再实现 | +| 小步修改 | 每次只修改当前阶段相关文件,避免顺手重构 | +| 用户变更保护 | 不得回滚或覆盖用户已有未提交变更 | +| 过程日志 | 每阶段记录关键命令结果和既有失败 | +| 阶段验证 | 每阶段完成后运行对应验证命令 | +| 阶段提交 | 每阶段验证通过后生成提交摘要;是否执行 `git commit` 由用户确认 | +| 回归保护 | 文件汇总、法规核查、自动填表现有测试不得回归 | +| doc 风险隔离 | `.doc` 原生能力不可用或原生处理失败不得阻断其他 6 个 docx 文件生成 | +| 外部依赖隔离 | LLM、通知、Word COM 均需可 mock,测试不依赖真实外部服务 | +| 下载安全 | 所有导出下载必须通过所属用户权限校验 | + +--- + +## 十九、推荐目标模式提示词 + +后续可直接对 Codex 输入: + +```text +请按 docs/5.开发计划/5.第1章监管信息材料包生成.md 执行开发。 + +执行要求: +1. 严格按 RIP-0 到 RIP-10 顺序推进,不跳阶段。 +2. 每阶段先读对应需求、功能、数据库、详细设计文档。 +3. 每阶段先写或补充测试,再实现代码。 +4. 每阶段只修改当前阶段相关文件,不做无关重构。 +5. 不回滚、不覆盖用户已有未提交变更。 +6. LLM、通知、Word COM 等外部能力必须可 mock。 +7. 每阶段完成后运行该阶段验证命令。 +8. 验证通过后生成提交摘要,是否本地提交等待用户确认。 +9. 最后使用 docs/0.原始材料/目标产品说明书.docx 做端到端验收。 +``` + +--- + +## 二十、待执行前检查清单 + +| 检查项 | 状态 | +| --- | --- | +| 需求分析、功能设计、数据库设计、详细设计均已存在 | 待执行时确认 | +| 当前分支是否适合创建开发分支 | 待执行时确认 | +| 是否存在用户未提交变更 | 待执行时确认 | +| `python-docx`、`openpyxl`、`PyYAML` 是否可用 | 待执行时确认 | +| Word COM 或 LibreOffice UNO 是否可用 | 待执行时确认,非阻塞 | +| 目标说明书 `docs/0.原始材料/目标产品说明书.docx` 是否存在 | 待执行时确认 | +| 样例模板目录 `docs/0.原始材料/第1章 监管信息` 是否完整 | 待执行时确认 | +| 现有文件汇总、法规核查、自动填表测试是否通过 | 待执行时确认 | + diff --git a/docs/6.待办计划/第二阶段暂缓事项.md b/docs/6.待办计划/第二阶段暂缓事项.md new file mode 100644 index 0000000..72d19d9 --- /dev/null +++ b/docs/6.待办计划/第二阶段暂缓事项.md @@ -0,0 +1,53 @@ +# 第二阶段暂缓事项待办表 + +## 一、待办原则 + +以下事项不进入第二阶段第一批或第二批落地范围。完成 Demo 主任务后,再根据展示效果和剩余时间决定是否进入第三阶段。 + +--- + +## 二、第三阶段第一批建议事项 + +| 编号 | 待办项 | 来源 | 建议优先级 | 说明 | +| --- | --- | --- | --- | --- | +| TODO-3-001 | 真实飞书 CLI/API 接入 | 第二阶段通知能力 | P0 | 替换第二阶段 mock 通知,支持真实发送 | +| TODO-3-002 | 用户与飞书账号映射 | 第二阶段通知能力 | P0 | 维护 Django User 到飞书 open_id、手机号或邮箱的映射 | +| TODO-3-003 | 飞书通知模板和失败重试完善 | 第二阶段通知能力 | P0 | 支持风险摘要、报告链接、重试、失败告警 | +| TODO-3-004 | 飞书通知权限和脱敏策略 | 第二阶段通知能力 | P1 | 通知中不暴露完整敏感文件内容 | + +--- + +## 三、规则管理后续事项 + +| 编号 | 待办项 | 来源 | 建议优先级 | 说明 | +| --- | --- | --- | --- | --- | +| TODO-RULE-001 | 规则管理前端 | YAML + DB 规则版本 | P1 | 展示 YAML 与数据库 hash 差异,支持人工确认导入 | +| TODO-RULE-002 | 规则导入审批流 | 合规追溯 | P1 | 规则版本变更需要审批和留痕 | +| TODO-RULE-003 | 规则/RAG 状态管理页 | RAG 运维 | P1 | 展示规则版本、YAML hash、Chroma 索引版本、索引状态和重建提示 | +| TODO-RULE-004 | RAG 索引重建前端入口 | RAG 运维 | P1 | 前端触发或提示重建法规 RAG 索引 | +| TODO-RULE-005 | 官网法规定期更新 | 原始需求法规来源 | P2 | 后续从 NMPA/CMDE 官网定期抓取或人工导入 | + +--- + +## 四、原始需求 3 后续事项 + +| 编号 | 待办项 | 来源 | 建议优先级 | 说明 | +| --- | --- | --- | --- | --- | +| TODO-FILL-001 | 字段级数据库表 | 第三批自动填表数据库设计 | P1 | 后续新增 `ApplicationFormFillField`,支持字段级查询、人工修改、审计和统计 | +| TODO-FILL-002 | PDF 转换与版式 QA | 第三批自动填表详细设计 | P1 | 使用 LibreOffice/soffice 将填好的 Word 转 PDF,并增加页数非 0、逐页截图或版式差异检查 | +| TODO-FILL-003 | `.doc` 模板预转换管理 | 第三批自动填表模板处理 | P1 | 将变更注册(备案)文件和安全和性能基本原则清单预转换为 `.docx` 工作模板,并人工确认版式 | +| TODO-FILL-004 | 安全和性能基本原则清单完整条目拆解 | 第三批自动填表模板配置 | P1 | 拆解清单条目编号、原则内容、适用性栏、证据栏和证明文件位置栏,写入 YAML 配置 | +| TODO-FILL-005 | 填写前后差异报告 | 自动填写风控 | P1 | 输出写入前后 diff,供人工复核 | +| TODO-FILL-006 | 自动填写审批确认 | 自动填写风控 | P1 | 文件写操作前支持人工确认或二次审批 | + +--- + +## 五、其他增强事项 + +| 编号 | 待办项 | 来源 | 建议优先级 | 说明 | +| --- | --- | --- | --- | --- | +| TODO-EXT-001 | 无汇总批次时自动串联文件汇总 | 第二阶段启动方式 | P2 | 当前口径为提示用户先自动汇总,暂不自动串联 | +| TODO-EXT-002 | 文件夹上传增强 | 第一阶段边界 | P2 | 浏览器 `webkitdirectory` 或目录上传能力 | +| TODO-EXT-003 | Office 精确分页 | 第一阶段边界 | P2 | 引入 LibreOffice headless 转 PDF 后统计页数 | +| TODO-EXT-004 | OCR 文本抽取 | 章节/一致性核查增强 | P2 | 支持扫描件和图片型 PDF | +| TODO-EXT-005 | 独立 Chroma Server 部署 | RAG 运维增强 | P2 | 当前第二阶段使用本地持久化 ChromaDB,后续可演进为独立服务 | diff --git a/docs/7.汇报材料/架构搭建思路汇报稿.md b/docs/7.汇报材料/架构搭建思路汇报稿.md new file mode 100644 index 0000000..9713145 --- /dev/null +++ b/docs/7.汇报材料/架构搭建思路汇报稿.md @@ -0,0 +1,311 @@ +# 架构搭建思路汇报稿(基于 Demo 版) + +## 一、设计路径:先锁规格,再实现代码 + +各位老师好,我本次 Demo 搭建的是一个面向体外诊断试剂注册资料准备与审核的智能体原型。 + +这次开发没有直接从代码开始,而是采用“文档先行、规格锁定、再实现代码”的路径。原因是注册资料审核不是一个简单问答场景,它涉及文件解析、法规规则、RAG 依据、工作流状态、导出文件、人工确认和整改闭环。如果一开始就写代码,很容易出现功能能跑但边界不清、结果不可追溯、后续难维护的问题。 + +所以整体设计路径分为四步: + +```text +需求拆解 +-> 生成需求分析、功能设计、详细设计、数据库设计和开发计划 +-> 用文档锁定实现规格 +-> 按规格实现 Django 代码、工作流、前端页面和测试 +``` + +当前仓库中可以看到完整的规格文档链路: + +| 阶段 | 产物 | 作用 | +| --- | --- | --- | +| 需求分析 | `docs/1.需求分析` | 明确业务目标、用户动作、输入输出和异常场景 | +| 功能设计 | `docs/2.功能设计` | 把需求拆成文件汇总、法规核查、自动填表、飞书通知等模块 | +| 详细设计 | `docs/3.详细设计` | 锁定工作流节点、字段结构、状态流转和服务边界 | +| 数据库设计 | `docs/4.数据库设计` | 锁定批次、附件、节点、风险项、导出文件等模型 | +| 开发计划 | `docs/5.开发计划` | 将实现拆成可验证的开发任务和前端线框图 | + +因此,这个 Demo 的核心不是“让大模型临时回答一个问题”,而是先用文档定义清楚系统应该如何工作,再把这些规格落实到代码、数据库、前端和测试中。最终形成的是一个可追溯、可复核、可继续扩展的审核工作台。 + +## 二、系统定位和 Demo 目标 + +这个 Demo 的目标不是简单做文件上传、文件解析或法规问答,而是把注册资料审核中几个高频、耗时、容易出错的环节串成一个智能工作流,包括: + +```text +资料上传 +-> 文件目录和页数汇总 +-> NMPA 法规完整性核查 +-> 法规依据 RAG 检索 +-> 产品关键信息抽取 +-> 一致性核查和风险预警 +-> 申报文件自动填表 +-> 报告导出和整改复核 +``` + +从产品形态上看,它更像是一个“注册资料审核工作台”。用户上传一批申报资料后,系统先把资料包结构化,再按法规规则做核查,然后输出风险清单、整改建议、证据来源和导出文件。后续还可以继续复用抽取到的产品信息,自动填入申报模板。 + +## 三、技术栈和总体架构 + +本 Demo 采用轻量、可本地运行、便于测试和可解释的技术栈。 + +| 层级 | 技术/工具 | 作用 | +| --- | --- | --- | +| Web 框架 | Django | 路由、视图、模板、认证、ORM 和后台能力 | +| 数据库 | SQLite / Django ORM | Demo 阶段保存会话、附件、批次、节点、风险项和导出文件 | +| 前端 | Django Template + 原生 JS + CSS | 实现首页工作台、审核智能体、知识库管理、附件管理和流式对话 | +| 文件解析 | `pypdf`、`python-docx`、`python-pptx`、`openpyxl`、`xlrd`、`py7zr`、`zipfile` | 解析 PDF、Word、PPT、Excel、压缩包和旧 Office 文件 | +| 规则配置 | YAML | 维护 NMPA 体外诊断试剂注册资料核查规则 | +| RAG | ChromaDB + embedding provider | 构建法规材料向量索引,检索法规依据片段 | +| LLM | SiliconFlow / 可配置大模型接口 | 做意图路由、低置信度抽取、自然语言总结和辅助复核 | +| 流式交互 | SSE | 将工作流启动、节点进度和模型回复实时推给前端 | +| 自动化验证 | pytest + Django test client | 验证路由、页面、模型、工作流和导出结果 | + +整体架构可以概括为: + +```text +用户界面 +-> Django 视图层 +-> 对话服务和 Skill 路由器 +-> 文件汇总 / 法规核查 / 自动填表工作流 +-> ORM 状态记录和导出文件 +-> RAG/LLM/规则服务 +-> 前端工作流卡片和报告下载 +``` + +这里的关键设计原则是:规则判断要稳定,RAG 负责补证据,LLM 做辅助,不把高风险合规结论完全交给大模型自由发挥。 + +## 四、对话流程:先识别意图,再决定 RAG 或工作流 + +审核智能体页面不是单纯把用户输入直接发给大模型,而是有一层对话编排流程。 + +一次用户消息进入系统后,大致会经历以下步骤: + +```text +用户输入 +-> 保存用户消息 +-> Skill Router 判断意图 +-> 根据意图选择普通问答、附件读取或工作流 +-> 必要时先检查附件和前置批次 +-> 启动对应工作流或执行 RAG 问答 +-> 保存助手回复和工作流事件 +-> 前端通过 SSE 展示增量内容和节点状态 +``` + +当前路由动作包括: + +| action | 场景 | 后续动作 | +| --- | --- | --- | +| `normal_chat` | 普通法规问答或项目问答 | 先检索知识库,再把 RAG 片段放入大模型上下文 | +| `attachment_reader` | 用户要求阅读、提取、总结上传附件 | 调用附件读取 Skill,返回文件内容摘要 | +| `file_summary` | 用户要求汇总文件目录、页数、清单 | 启动文件汇总工作流 | +| `regulatory_review` | 用户要求法规核查、完整性核查、风险预警、整改建议 | 必要时先生成文件汇总批次,再启动法规核查工作流 | +| `application_form_fill` | 用户要求申报文件填表、模板填充、安全和性能清单 | 必要时先生成文件汇总批次,再启动自动填表工作流 | + +也就是说,普通问题是“先 RAG,再回答”;工作流问题是“先路由,再检查前置条件,再启动工作流”。例如用户问“注册检验报告要求是什么”,系统会走 RAG 问答;用户说“请对当前资料做法规核查”,系统会进入法规核查工作流。 + +## 五、Skill 调用方式:路由器统一调度工具能力 + +Demo 中的 Skill 不是一个单独页面,而是对话服务后面的工具调用机制。用户不需要手动选择复杂功能,系统会根据用户话语和当前附件状态判断是否调用某个 Skill 或工作流。 + +当前实现中,`review_agent/skill_router.py` 负责意图路由。它采用两层判断: + +```text +确定性规则预判 +-> LLM 路由判断 +-> 规则兜底 +``` + +第一层是确定性规则。例如用户输入中包含“法规核查”“NMPA 核查”“风险预警”“自动填表”“申报模板”等明确关键词,系统可以直接判断要启动对应工作流。这样可以避免每次都依赖大模型判断。 + +第二层是 LLM 路由。系统会把用户消息和当前 active 附件列表发给路由模型,让模型只输出结构化 JSON: + +```json +{ + "action": "regulatory_review", + "confidence": 0.9, + "reason": "用户要求对当前注册资料进行法规完整性核查" +} +``` + +第三层是规则兜底。如果 LLM 不可用、配置缺失或返回异常,系统会退回关键词和附件状态判断,保证 Demo 在本地环境也能稳定运行。 + +这个设计的好处是:用户体验上像是在和一个智能体对话,技术实现上则是由路由器把对话分发到不同工具、不同工作流和不同数据服务。 + +## 六、RAG 方式:法规依据和用户知识库共同参与 + +RAG 在 Demo 中有两类来源: + +| 来源 | 说明 | +| --- | --- | +| 内置法规材料 | 来自 `docs/0.原始材料` 和 NMPA 相关法规文件,用于法规依据检索 | +| 用户管理知识库 | 由用户在“知识库管理”页面上传,可作为当前账号所有对话的补充知识 | + +法规材料会被切分为文本块,写入 ChromaDB 向量库。每个 chunk 保留来源文件、chunk 编号、文本片段和元数据。embedding 支持真实语义 embedding,也支持 deterministic/local embedding,后者主要用于测试和 dry run。 + +RAG 在系统中的定位有两种: + +### 1. 普通问答中的 RAG + +如果用户提出普通问题,系统会先检索知识库,把命中的法规片段或用户知识库片段拼入上下文,再调用大模型回答。这样回答不会只依赖模型记忆,而是带有本地法规材料和用户资料依据。 + +```text +用户问题 +-> 知识库检索 +-> 过滤和排序相关片段 +-> 组装为知识上下文 +-> 调用 LLM 生成回答 +``` + +### 2. 工作流中的 RAG + +在法规核查工作流里,RAG 不直接决定是否合规,而是为规则判断补充法规依据。例如结构化规则已经判断“缺少注册检验报告”,RAG 再检索相关法规要求,给出来源文件和依据片段。 + +这种方式避免了“让大模型自由判断合规”的不稳定性,同时让报告具备可解释依据。 + +## 七、三条核心工作流 + +当前 Demo 拆成三条主链路:文件汇总、法规核查、自动填表。 + +### 1. 文件汇总链路 + +对应模块:`review_agent/file_summary` + +```text +文件上传 +-> 附件固化 +-> 压缩包解压 +-> 文件扫描 +-> 页数统计 +-> 产品名识别 +-> Markdown/Excel 报告输出 +``` + +这个链路负责把原始资料包转换成结构化文件清单。系统会生成 `FileSummaryBatch` 和 `FileSummaryItem`,后续法规核查和自动填表都复用这套文件清单,不再重复扫描资料。 + +输出字段包括序号、目录层级、文件名、文件类型、页数、相对路径、统计状态、重试次数和异常说明。 + +### 2. 法规核查链路 + +对应模块:`review_agent/regulatory_review` + +```text +准备资料 +-> 适用条件确认 +-> 规则范围裁剪 +-> 完整性核查 +-> 文本抽取 +-> 章节核查 +-> 一致性核查 +-> RAG 法规依据补充 +-> 风险评估 +-> 报告输出 +-> 整改复核 +``` + +这条链路使用 `review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml` 作为结构化规则文件。规则中配置了附件 4 的资料要求,包括监管信息、综述资料、非临床资料、临床评价资料、说明书和标签样稿、质量管理体系文件等。 + +系统会检查是否缺少关键资料,例如注册申请表、符合性声明、产品技术要求、注册检验报告、说明书、标签样稿、临床评价资料和质量管理体系文件。缺失项会转成 `RegulatoryIssue`,并按阻断项、高风险、中风险、低风险和提示项分级。 + +### 3. 自动填表链路 + +对应模块:`review_agent/application_form_fill` + +```text +准备资料 +-> 模板选择 +-> 模板复制 +-> 字段抽取 +-> 冲突归并 +-> Word 填写 +-> 追溯清单导出 +-> 结果通知 +``` + +这条链路会复用前面抽取到的产品信息,自动选择申报模板,并将字段填入 Word 模板。对于冲突字段,Demo 中采用明确的归并策略,同时在结果中保留冲突摘要和来源追溯。 + +## 八、页面和数据工作台 + +前端目前包括四个主要页面: + +| 页面 | URL | 作用 | +| --- | --- | --- | +| 首页工作台 | `/` | 展示对话、附件、知识库、批次状态和最近处理记录 | +| 审核智能体 | `/chat/` | 对话、上传附件、启动工作流、查看节点进度 | +| 知识库管理 | `/knowledge-base/` | 管理用户上传知识库、查看内置法规材料和索引状态 | +| 附件管理 | `/attachments/` | 管理不同对话下的上传附件、版本、启用状态和下载 | + +首页工作台重点不是营销展示,而是运行态数据,包括: + +```text +对话总数 +附件总数 +知识库材料数 +执行中批次 +已处理批次 +成功批次 +等待确认批次 +失败批次 +最近处理记录 +``` + +知识库材料中同时统计用户管理文档和内置法规材料,避免把“知识库”误解成只包含用户上传文件。 + +## 九、过程留痕和可追溯设计 + +审核类系统不能只输出一个结论,还必须说明结论从哪里来。因此 Demo 对关键过程都做了结构化留痕。 + +| 过程 | 留痕内容 | +| --- | --- | +| 对话 | 用户消息、助手消息、会话标题、更新时间 | +| 附件 | 原始文件名、版本号、启用状态、存储路径、文件大小 | +| 文件汇总 | 批次号、文件明细、页数、统计状态、异常说明 | +| 工作流节点 | 节点编码、节点名称、进度、状态、错误信息 | +| 法规核查 | 规则编码、缺失项、风险等级、证据、整改建议 | +| RAG 检索 | 来源文件、片段文本、相似度、chunk 元数据 | +| 自动填表 | 字段来源、冲突摘要、模板选择、追溯清单 | +| 导出文件 | Markdown、Excel、JSON、Word 等结果文件 | + +这保证了 Demo 输出的结果不是一次性回答,而是可以复核、下载、整改和继续追踪的过程资产。 + +## 十、Demo 可展示结果 + +本次 Demo 可以展示以下核心结果: + +### 1. 文件目录汇总表 + +用户上传注册资料文件夹、散装文件或压缩包后,系统自动完成附件固化、解压、扫描和页数统计,最终生成 Markdown 汇总报告和 Excel 明细表。 + +### 2. 法规完整性报告 + +系统基于文件汇总结果和 NMPA 规则库做完整性核查,输出 Markdown 法规核查报告、Excel 问题清单和 JSON 结构化结果包。 + +### 3. 产品关键信息提取对照表 + +系统从说明书、产品技术要求、注册检验报告、申请表等文件中抽取产品名称、型号规格、预期用途、管理类别、分类编码、注册类型和临床评价路径,并保留来源文件和证据片段。 + +### 4. 风险预警列表 + +系统把完整性缺失、章节异常、字段冲突、文本抽取失败、页数不可确定、通知失败等问题统一沉淀为风险项,并按阻断项、高风险、中风险、低风险和提示项分级。 + +### 5. 申报文件自动填表结果 + +系统根据资料内容和适用条件选择模板,自动填充 Word 文件,并导出字段追溯清单,说明每个字段来自哪个文件、哪个证据片段。 + +## 十一、总结 + +整体来看,本 Demo 的架构搭建思路可以概括为: + +```text +先用文档锁定规格 +再用规则结构化审核逻辑 +再用 RAG 补充法规依据 +再用 Skill Router 调度工具和工作流 +再用 ORM 和导出文件沉淀过程资产 +最后通过工作台页面呈现状态和结果 +``` + +它体现的是一个“资料输入、规则判断、证据追溯、风险输出、整改闭环”的智能体原型。 + +当前 Demo 已经完成了首页工作台、审核智能体对话、附件管理、知识库管理、文件汇总、法规核查、RAG 依据检索、风险预警、报告导出和自动填表主链路。后续如果继续增强,可以重点补充 OCR、扫描件识别、复杂 PDF 版式解析、规则后台维护、人工确认界面、飞书真实消息闭环,以及更完整的多智能体编排能力。 + +最终希望这个智能体能够从一个 Demo 原型,逐步演进为注册资料准备和审核过程中的智能协作平台。 diff --git a/manage.py b/manage.py index f90c4b8..f1b0e57 100644 --- a/manage.py +++ b/manage.py @@ -1,12 +1,17 @@ #!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" import os import sys def main(): os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") - from django.core.management import execute_from_command_line - + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Is it installed and available on your PYTHONPATH?" + ) from exc execute_from_command_line(sys.argv) diff --git a/requirements.txt b/requirements.txt index dc850f3..0c4aaa8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,12 @@ -Django>=5.1,<6.0 -PyYAML>=6.0,<7.0 -chromadb>=0.5,<1.0 -pytest>=8.0,<9.0 -pytest-django>=4.9,<5.0 +Django>=5.0,<6.0 +pypdf>=5.0 +python-docx>=1.1 +python-pptx>=1.0 +openpyxl>=3.1 +xlrd>=2.0 +olefile>=0.47 +py7zr>=0.21 +playwright>=1.60 +PyYAML>=6.0 +chromadb>=0.5 +httpx>=0.27 diff --git a/review_agent/__init__.py b/review_agent/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/review_agent/__init__.py @@ -0,0 +1 @@ + diff --git a/review_agent/admin.py b/review_agent/admin.py new file mode 100644 index 0000000..4465f6f --- /dev/null +++ b/review_agent/admin.py @@ -0,0 +1,74 @@ +from django.contrib import admin + +from review_agent.models import ( + FeishuAccessTokenCache, + FeishuQuestionLog, + FeishuUserMapping, + WorkflowNotificationRecord, +) + + +@admin.register(FeishuUserMapping) +class FeishuUserMappingAdmin(admin.ModelAdmin): + list_display = ( + "system_user", + "feishu_display_name", + "feishu_open_id", + "feishu_user_id", + "feishu_mobile", + "is_active", + "updated_at", + ) + list_filter = ("is_active",) + search_fields = ( + "system_user__username", + "feishu_display_name", + "feishu_open_id", + "feishu_user_id", + "feishu_mobile", + ) + readonly_fields = ("created_at", "updated_at") + + +@admin.register(FeishuAccessTokenCache) +class FeishuAccessTokenCacheAdmin(admin.ModelAdmin): + list_display = ("app_id_hash", "expires_at", "updated_at", "has_error") + search_fields = ("app_id_hash", "error_message") + readonly_fields = ("created_at", "updated_at") + + @admin.display(boolean=True, description="有错误") + def has_error(self, obj: FeishuAccessTokenCache) -> bool: + return bool(obj.error_message) + + +@admin.register(WorkflowNotificationRecord) +class WorkflowNotificationRecordAdmin(admin.ModelAdmin): + list_display = ( + "workflow_type", + "workflow_batch_no", + "workflow_status", + "channel", + "send_status", + "target", + "sent_at", + "created_at", + ) + list_filter = ("workflow_type", "channel", "send_status", "workflow_status") + search_fields = ("workflow_batch_no", "dedupe_key", "target", "error_message") + readonly_fields = ("created_at", "updated_at") + + +@admin.register(FeishuQuestionLog) +class FeishuQuestionLogAdmin(admin.ModelAdmin): + list_display = ( + "system_user", + "source_type", + "intent", + "permission_result", + "status", + "processed_at", + "created_at", + ) + list_filter = ("source_type", "intent", "permission_result", "status") + search_fields = ("system_user__username", "question_text", "answer_summary", "message_id") + readonly_fields = ("created_at",) diff --git a/review_agent/application_form_fill/__init__.py b/review_agent/application_form_fill/__init__.py new file mode 100644 index 0000000..3a7b8c0 --- /dev/null +++ b/review_agent/application_form_fill/__init__.py @@ -0,0 +1 @@ +"""Application form auto-fill workflow package.""" diff --git a/review_agent/application_form_fill/constants.py b/review_agent/application_form_fill/constants.py new file mode 100644 index 0000000..a4082e6 --- /dev/null +++ b/review_agent/application_form_fill/constants.py @@ -0,0 +1,36 @@ +WORKFLOW_TYPE = "application_form_fill" + +TEMPLATE_REGISTRATION_CERTIFICATE = "registration_certificate" +TEMPLATE_CHANGE_REGISTRATION = "change_registration" +TEMPLATE_ESSENTIAL_PRINCIPLES = "essential_principles" + +DEFAULT_OUTPUT_TYPES = ["word", "excel", "json"] + +FORM_FILL_TRIGGER_KEYWORDS = [ + "填注册证", + "对应的表格", + "生成申报模板", + "安全和性能基本原则清单", + "填到申报模板", + "自动填表", + "生成表格", + "申报文件模板", + "申报文件填表", + "产品关键信息", + "字段来源追溯清单", + "注册证 word", +] + +FORM_FILL_NODE_DEFINITIONS = [ + ("prepare", "准备资料", "form_fill"), + ("template_select", "选择模板", "form_fill"), + ("template_copy", "复制模板", "form_fill"), + ("field_extract", "抽取字段", "form_fill"), + ("conflict_merge", "冲突归并", "form_fill"), + ("word_fill", "填写 Word", "form_fill"), + ("pdf_convert", "转换 PDF", "form_fill"), + ("trace_export", "追溯清单", "form_fill"), + ("output_export", "输出下载", "form_fill"), + ("notify", "飞书通知", "form_fill"), + ("completed", "完成", "completed"), +] diff --git a/review_agent/application_form_fill/events.py b/review_agent/application_form_fill/events.py new file mode 100644 index 0000000..be7ec28 --- /dev/null +++ b/review_agent/application_form_fill/events.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from review_agent.application_form_fill.constants import WORKFLOW_TYPE +from review_agent.models import ApplicationFormFillBatch, WorkflowEvent + + +def record_event( + batch: ApplicationFormFillBatch, + event_type: str, + payload: dict | None = None, +) -> WorkflowEvent: + return WorkflowEvent.objects.create( + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=batch.pk, + conversation=batch.conversation, + event_type=event_type, + payload=payload or {}, + ) + + +def serialize_event(event: WorkflowEvent) -> dict[str, object]: + return { + "id": event.pk, + "event_type": event.event_type, + "payload": event.payload, + "created_at": event.created_at.isoformat(), + } diff --git a/review_agent/application_form_fill/prompts/field_extract.md b/review_agent/application_form_fill/prompts/field_extract.md new file mode 100644 index 0000000..6ff1461 --- /dev/null +++ b/review_agent/application_form_fill/prompts/field_extract.md @@ -0,0 +1,23 @@ +你是医疗器械体外诊断试剂申报资料字段抽取助手。 + +请只输出 JSON 对象,不要输出 Markdown。结构如下: + +{ + "fields": [ + { + "key": "product_name", + "label": "产品名称", + "value": "字段值", + "source_file": "来源文件名", + "source_role": "说明书", + "evidence": "原文证据", + "confidence": 0.8 + } + ], + "checklist_items": [] +} + +要求: +- 只抽取输入模板字段中出现的信息。 +- 字段值必须来自资料原文,不要编造。 +- 找不到时不要输出该字段。 diff --git a/review_agent/application_form_fill/schemas.py b/review_agent/application_form_fill/schemas.py new file mode 100644 index 0000000..de89257 --- /dev/null +++ b/review_agent/application_form_fill/schemas.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from review_agent.models import ApplicationFormFillBatch, ExportedSummaryFile, FileSummaryBatch, RegulatoryReviewBatch + + +@dataclass(frozen=True) +class TemplateSpec: + code: str + name: str + source_file: str + output_label: str + applies_when: dict[str, Any] + file_format: str + fields: list[dict[str, Any]] + checklist_items: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass(frozen=True) +class ExtractedField: + key: str + label: str + value: str + source_file: str + source_role: str + evidence: str + extractor: str + confidence: float + + +@dataclass(frozen=True) +class MergedField: + key: str + label: str + value: str + source_file: str + evidence: str + confidence: float + has_conflict: bool = False + conflict_values: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class FormFillContext: + batch: ApplicationFormFillBatch + source_summary_batch: FileSummaryBatch + source_regulatory_batch: RegulatoryReviewBatch | None + template_config: dict[str, Any] = field(default_factory=dict) + selected_templates: list[TemplateSpec] = field(default_factory=list) + document_texts: dict[str, str] = field(default_factory=dict) + regex_results: dict[str, Any] = field(default_factory=dict) + llm_results: dict[str, Any] = field(default_factory=dict) + merged_fields: dict[str, MergedField] = field(default_factory=dict) + checklist_items: dict[str, Any] = field(default_factory=dict) + conflicts: list[dict[str, Any]] = field(default_factory=list) + exports: list[ExportedSummaryFile] = field(default_factory=list) diff --git a/review_agent/application_form_fill/services/__init__.py b/review_agent/application_form_fill/services/__init__.py new file mode 100644 index 0000000..d92b991 --- /dev/null +++ b/review_agent/application_form_fill/services/__init__.py @@ -0,0 +1 @@ +"""Application form auto-fill services.""" diff --git a/review_agent/application_form_fill/services/field_extract.py b/review_agent/application_form_fill/services/field_extract.py new file mode 100644 index 0000000..35207da --- /dev/null +++ b/review_agent/application_form_fill/services/field_extract.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import json +import re +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any + +from django.conf import settings + +from review_agent.application_form_fill.schemas import ExtractedField, TemplateSpec +from review_agent.application_form_fill.storage import create_artifact_for_file, ensure_batch_subdir +from review_agent.llm import generate_completion +from review_agent.models import ApplicationFormFillArtifact, ApplicationFormFillBatch, FileSummaryBatch +from review_agent.regulatory_review.services.text_extract import extract_text + + +FIELD_ALIASES = { + "product_name": ["产品名称"], + "applicant_name": ["注册人名称", "申请人名称", "生产企业名称"], + "applicant_address": ["注册人住所", "申请人住所", "生产企业住所"], + "manufacturer_address": ["生产地址", "生产企业地址", "生产场所"], + "agent_name": ["代理人名称", "生产企业名称", "注册人名称", "申请人名称"], + "agent_address": ["代理人住所", "生产企业住所", "注册人住所", "申请人住所"], + "package_specification": ["包装规格", "规格"], + "main_components": ["主要组成成分", "主要组成", "组成成分"], + "intended_use": ["预期用途"], + "storage_condition_and_validity": ["产品储存条件及有效期", "储存条件及有效期", "储存条件", "有效期"], +} + +STATIC_STOP_LABELS = [ + "申请人", + "国家药品监督管理局", + "填表说明", + "注", + "保证书", + "应附资料", + "优先通道申请", + "分类编码", + "医疗器械唯一标识", + "注册产品目前是否", + "临床评价路径", + "临床试验", + "其他需要说明的问题", + "国家药监局器审中心医疗器械", +] + + +def collect_document_texts(summary_batch: FileSummaryBatch) -> dict[str, str]: + texts: dict[str, str] = {} + for item in summary_batch.items.order_by("file_index"): + path = Path(item.storage_path) + if not path.is_absolute(): + path = Path(settings.MEDIA_ROOT) / item.storage_path + if not path.exists(): + continue + result = extract_text(path) + if result.status == "success" and result.text: + texts[item.file_name] = result.text + return texts + + +def extract_by_rules(texts: dict[str, str], specs: list[TemplateSpec]) -> dict[str, Any]: + fields: list[dict[str, Any]] = [] + field_defs = _field_defs(specs) + labels = _all_field_labels(field_defs) + for file_name, text in texts.items(): + source_role = detect_source_role(file_name, text) + for field in field_defs: + value, evidence = _extract_field_value(text, field, labels) + if not value: + continue + fields.append( + ExtractedField( + key=field["key"], + label=field["label"], + value=value, + source_file=file_name, + source_role=source_role, + evidence=evidence, + extractor="rule", + confidence=0.75 if source_role == "说明书" else 0.65, + ).__dict__ + ) + return {"fields": fields, "checklist_items": []} + + +def extract_by_llm(texts: dict[str, str], specs: list[TemplateSpec]) -> dict[str, Any]: + try: + raw = generate_completion( + [ + {"role": "system", "content": _prompt_text()}, + {"role": "user", "content": _build_llm_user_prompt(texts, specs)}, + ], + temperature=0.0, + ) + payload = _parse_json_object(raw) + except Exception as exc: + return {"fields": [], "checklist_items": [], "error_message": str(exc)} + + fields = [] + allowed_keys = {field["key"] for field in _field_defs(specs)} + for item in payload.get("fields") or []: + if not isinstance(item, dict) or item.get("key") not in allowed_keys or not item.get("value"): + continue + fields.append( + { + "key": str(item.get("key") or ""), + "label": str(item.get("label") or item.get("key") or ""), + "value": str(item.get("value") or "").strip(), + "source_file": str(item.get("source_file") or ""), + "source_role": str(item.get("source_role") or detect_source_role(str(item.get("source_file") or ""), "")), + "evidence": str(item.get("evidence") or "").strip(), + "extractor": "llm", + "confidence": _float_confidence(item.get("confidence"), default=0.7), + } + ) + return {"fields": fields, "checklist_items": payload.get("checklist_items") or []} + + +def run_parallel_extract(texts: dict[str, str], specs: list[TemplateSpec]) -> dict[str, Any]: + with ThreadPoolExecutor(max_workers=2) as executor: + rule_future = executor.submit(extract_by_rules, texts, specs) + llm_future = executor.submit(extract_by_llm, texts, specs) + regex_results = rule_future.result() + llm_results = llm_future.result() + return { + "regex_results": regex_results, + "llm_results": llm_results, + "selected_templates": [spec.code for spec in specs], + "source_evidence": [{"source_file": name, "char_count": len(text)} for name, text in texts.items()], + } + + +def save_field_extract_result(batch: ApplicationFormFillBatch, payload: dict[str, Any]) -> ApplicationFormFillArtifact: + target_dir = ensure_batch_subdir(batch, "exports") + path = target_dir / "field_extract_result.json" + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return create_artifact_for_file( + batch, + path=path, + artifact_type=ApplicationFormFillArtifact.ArtifactType.FIELD_EXTRACT_RESULT, + file_format=ApplicationFormFillArtifact.FileFormat.JSON, + name="field_extract_result", + metadata={"artifact": "field_extract_result"}, + created_by_node="field_extract", + ) + + +def detect_source_role(file_name: str, text: str = "") -> str: + target = f"{file_name}\n{text[:200]}" + if "说明书" in target: + return "说明书" + if "产品技术要求" in target: + return "产品技术要求" + if "注册检验" in target or "检测报告" in target: + return "注册检验报告" + if "性能研究" in target: + return "性能研究资料" + if "申请表" in target: + return "申请表" + return "其他注册资料" + + +def _field_defs(specs: list[TemplateSpec]) -> list[dict[str, str]]: + fields: list[dict[str, str]] = [] + for spec in specs: + for field in spec.fields: + key = str(field.get("key") or "") + label = str(field.get("label") or "") + if key and label: + fields.append({"key": key, "label": label}) + return fields + + +def _extract_field_value(text: str, field: dict[str, str], labels: list[str]) -> tuple[str, str]: + aliases = _field_aliases(field) + for label in aliases: + value, evidence = _extract_colon_label_value(text, label, labels + aliases) + if value: + return value, evidence + value, evidence = _extract_bracket_section_value(text, label) + if value: + return value, evidence + return "", "" + + +def _field_aliases(field: dict[str, str]) -> list[str]: + aliases = [field["label"]] + aliases.extend(FIELD_ALIASES.get(field["key"], [])) + result: list[str] = [] + for alias in aliases: + normalized = str(alias or "").strip() + if normalized and normalized not in result: + result.append(normalized) + return result + + +def _all_field_labels(fields: list[dict[str, str]]) -> list[str]: + labels: list[str] = list(STATIC_STOP_LABELS) + for field in fields: + for label in _field_aliases(field): + if label not in labels: + labels.append(label) + return labels + + +def _extract_label_value(text: str, label: str, labels: list[str]) -> tuple[str, str]: + return _extract_colon_label_value(text, label, labels) + + +def _extract_colon_label_value(text: str, label: str, labels: list[str]) -> tuple[str, str]: + escaped_labels = "|".join(re.escape(item) for item in labels if item != label) + stop_pattern = rf"(?=\n\s*(?:{escaped_labels})(?:\s*[::]|\s*$))" if escaped_labels else r"(?=\Z)" + pattern = re.compile(rf"{re.escape(label)}\s*[::]\s*(.+?)(?:{stop_pattern}|\Z)", re.S) + match = pattern.search(text or "") + if not match: + return "", "" + raw = match.group(1).strip() + value = re.sub(r"\n{2,}.*\Z", "", raw, flags=re.S).strip() + value = "\n".join(line.strip() for line in value.splitlines() if line.strip()) + evidence = f"{label}:{value}"[:300] + return value, evidence + + +def _extract_bracket_section_value(text: str, label: str) -> tuple[str, str]: + heading_pattern = rf"^\s*[【\[]\s*{re.escape(label)}\s*[】\]]\s*$" + lines = (text or "").splitlines() + for index, line in enumerate(lines): + if not re.match(heading_pattern, line.strip()): + continue + value_parts: list[str] = [] + for next_line in lines[index + 1 :]: + normalized = next_line.strip() + if not normalized: + continue + if _looks_like_bracket_heading(normalized): + break + value_parts.append(normalized) + value = "\n".join(value_parts).strip() + if value: + return value, f"【{label}】\n{value}"[:300] + return "", "" + + +def _looks_like_bracket_heading(line: str) -> bool: + return bool(re.match(r"^\s*[【\[].{1,40}[】\]]\s*$", line)) + + +def _prompt_text() -> str: + path = Path(__file__).resolve().parents[1] / "prompts" / "field_extract.md" + return path.read_text(encoding="utf-8") + + +def _build_llm_user_prompt(texts: dict[str, str], specs: list[TemplateSpec]) -> str: + fields = [{"key": field["key"], "label": field["label"]} for field in _field_defs(specs)] + documents = [{"source_file": name, "text": text[:4000]} for name, text in texts.items()] + return json.dumps({"fields": fields, "documents": documents}, ensure_ascii=False) + + +def _parse_json_object(raw: str) -> dict[str, Any]: + text = (raw or "").strip() + if text.startswith("```"): + text = text.strip("`").strip() + if text.lower().startswith("json"): + text = text[4:].strip() + start = text.find("{") + end = text.rfind("}") + if start == -1 or end == -1 or end < start: + raise json.JSONDecodeError("未找到 JSON 对象", text, 0) + return json.loads(text[start : end + 1]) + + +def _float_confidence(value, *, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default diff --git a/review_agent/application_form_fill/services/field_merge.py b/review_agent/application_form_fill/services/field_merge.py new file mode 100644 index 0000000..bbc9eb5 --- /dev/null +++ b/review_agent/application_form_fill/services/field_merge.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import re +from typing import Any + +from review_agent.application_form_fill.schemas import MergedField + + +SOURCE_PRIORITY = { + "说明书": 1, + "产品技术要求": 2, + "注册检验报告": 3, + "检测报告": 3, + "性能研究资料": 4, + "其他注册资料": 5, +} + + +def normalize_field_value(value: str) -> str: + return re.sub(r"\s+", "", str(value or "")).strip().lower() + + +def rank_source(source_role: str, source_file: str = "") -> int: + target = f"{source_role}\n{source_file}" + for keyword, rank in SOURCE_PRIORITY.items(): + if keyword in target: + return rank + return 9 + + +def merge_fields(regex_results: dict[str, Any], llm_results: dict[str, Any]) -> tuple[dict[str, MergedField], list[dict]]: + grouped: dict[str, list[dict[str, Any]]] = {} + for item in list(regex_results.get("fields") or []) + list(llm_results.get("fields") or []): + key = str(item.get("key") or "") + value = str(item.get("value") or "").strip() + if not key or not value: + continue + grouped.setdefault(key, []).append(item) + + merged: dict[str, MergedField] = {} + conflicts: list[dict] = [] + for key, candidates in grouped.items(): + selected = sorted( + candidates, + key=lambda item: ( + rank_source(str(item.get("source_role") or ""), str(item.get("source_file") or "")), + -float(item.get("confidence") or 0), + ), + )[0] + distinct = _distinct_values(candidates) + has_conflict = len(distinct) > 1 + conflict_values = [ + { + "value": item.get("value"), + "source_file": item.get("source_file", ""), + "source_role": item.get("source_role", ""), + "evidence": item.get("evidence", ""), + } + for item in candidates + if normalize_field_value(str(item.get("value") or "")) != normalize_field_value(str(selected.get("value") or "")) + ] + merged_field = MergedField( + key=key, + label=str(selected.get("label") or key), + value=str(selected.get("value") or ""), + source_file=str(selected.get("source_file") or ""), + evidence=str(selected.get("evidence") or ""), + confidence=float(selected.get("confidence") or 0), + has_conflict=has_conflict, + conflict_values=conflict_values, + ) + merged[key] = merged_field + if has_conflict: + conflicts.append( + { + "field_key": key, + "field_label": merged_field.label, + "selected_value": merged_field.value, + "selected_source": merged_field.source_file, + "conflict_values": conflict_values, + "handling": "说明书优先,模板内黄底红字高亮" if rank_source(merged_field.source_file, merged_field.source_file) == 1 else "按来源优先级采用最高优先级字段", + } + ) + _apply_agent_company_fallbacks(merged) + return merged, conflicts + + +def _distinct_values(candidates: list[dict[str, Any]]) -> set[str]: + return {normalize_field_value(str(item.get("value") or "")) for item in candidates if item.get("value")} + + +def _apply_agent_company_fallbacks(merged: dict[str, MergedField]) -> None: + fallback_pairs = { + "agent_name": ("applicant_name", "代理人名称"), + "agent_address": ("applicant_address", "代理人住所"), + } + for target_key, (source_key, target_label) in fallback_pairs.items(): + if target_key in merged or source_key not in merged: + continue + source = merged[source_key] + merged[target_key] = MergedField( + key=target_key, + label=target_label, + value=source.value, + source_file=source.source_file, + evidence=source.evidence, + confidence=source.confidence, + has_conflict=source.has_conflict, + conflict_values=source.conflict_values, + ) diff --git a/review_agent/application_form_fill/services/notifier.py b/review_agent/application_form_fill/services/notifier.py new file mode 100644 index 0000000..0b9c93d --- /dev/null +++ b/review_agent/application_form_fill/services/notifier.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from django.utils import timezone + +from review_agent.models import ( + ApplicationFormFillBatch, + ApplicationFormFillNotificationRecord, + ExportedSummaryFile, +) +from review_agent.notifications.dispatcher import dispatch_workflow_notification +from review_agent.notifications.workflow_adapters import build_application_form_fill_context + + +def notify_completion( + batch: ApplicationFormFillBatch, + exports: list[ExportedSummaryFile], + *, + fail: bool = False, +) -> ApplicationFormFillNotificationRecord: + export_ids = [export.pk for export in exports] + message_summary = ( + f"自动填表批次 {batch.batch_no} 已完成," + f"模板 {', '.join(batch.selected_templates or []) or '未识别'}," + f"冲突字段 {len(batch.conflict_summary or [])} 个。" + ) + if fail: + return ApplicationFormFillNotificationRecord.objects.create( + batch=batch, + recipient=batch.user, + channel=ApplicationFormFillNotificationRecord.Channel.MOCK, + template_codes=batch.selected_templates, + export_ids=export_ids, + message_summary=message_summary, + send_status=ApplicationFormFillNotificationRecord.SendStatus.FAILED, + retry_count=1, + error_message="mock notification failed", + ) + unified_error = "" + try: + unified_record = dispatch_workflow_notification(build_application_form_fill_context(batch)) + if unified_record.send_status == unified_record.SendStatus.FAILED: + unified_error = unified_record.error_message + except Exception as exc: + unified_error = str(exc) + return ApplicationFormFillNotificationRecord.objects.create( + batch=batch, + recipient=batch.user, + channel=ApplicationFormFillNotificationRecord.Channel.MOCK, + template_codes=batch.selected_templates, + export_ids=export_ids, + message_summary=message_summary, + send_status=ApplicationFormFillNotificationRecord.SendStatus.SUCCESS, + error_message=unified_error, + sent_at=timezone.now(), + ) diff --git a/review_agent/application_form_fill/services/summary.py b/review_agent/application_form_fill/services/summary.py new file mode 100644 index 0000000..bb4d663 --- /dev/null +++ b/review_agent/application_form_fill/services/summary.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from review_agent.models import ApplicationFormFillBatch, ExportedSummaryFile + + +def build_assistant_summary(batch: ApplicationFormFillBatch, exports: list[ExportedSummaryFile]) -> str: + word_exports = [export for export in exports if export.export_type == ExportedSummaryFile.ExportType.WORD] + trace_exports = [ + export + for export in exports + if export.export_type in {ExportedSummaryFile.ExportType.EXCEL, ExportedSummaryFile.ExportType.JSON} + ] + lines = ["已生成申报模板自动填表文件。", "", "| 文件 | Word | PDF |", "| --- | --- | --- |"] + if word_exports: + for export in word_exports: + lines.append(f"| {export.file_name} | [下载](/api/review-agent/file-summary/exports/{export.pk}/download/) | 待增强 |") + else: + lines.append("| 自动填表结果 | 未生成 | 待增强 |") + + conflicts = batch.conflict_summary or [] + if conflicts: + lines.extend(["", "| 冲突字段 | 采用值 | 冲突来源 | 处理 |", "| --- | --- | --- | --- |"]) + for item in conflicts: + conflict_sources = ";".join( + f"{_compact_table_text(value.get('source_file', ''))}:{_compact_table_text(value.get('value', ''))}" + for value in item.get("conflict_values", []) + ) + lines.append( + f"| {_compact_table_text(item.get('field_label', item.get('field_key', '')))} | {_compact_table_text(item.get('selected_value', ''))} | {_compact_table_text(conflict_sources or '-')} | {_compact_table_text(item.get('handling', ''))} |" + ) + + if trace_exports: + lines.append("") + for export in trace_exports: + lines.append(f"[下载{export.file_name}](/api/review-agent/file-summary/exports/{export.pk}/download/)") + return "\n".join(lines).strip() + + +def _compact_table_text(value: object, *, limit: int = 80) -> str: + text = " ".join(str(value or "").replace("|", " ").split()) + if len(text) <= limit: + return text + return f"{text[:limit]}..." diff --git a/review_agent/application_form_fill/services/template_config.py b/review_agent/application_form_fill/services/template_config.py new file mode 100644 index 0000000..b2538b1 --- /dev/null +++ b/review_agent/application_form_fill/services/template_config.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Any + +import yaml +from django.conf import settings + + +DEFAULT_CONFIG_PATH = ( + Path(settings.BASE_DIR) + / "review_agent" + / "application_form_fill" + / "templates" + / "application_form_templates_v1.yaml" +) + +SUPPORTED_TARGET_TYPES = {"table_row", "placeholder"} +SUPPORTED_FILE_FORMATS = {"doc", "docx"} + + +def load_template_config(path: str | Path | None = None) -> dict[str, Any]: + config_path = Path(path) if path else DEFAULT_CONFIG_PATH + with config_path.open("r", encoding="utf-8") as handle: + payload = yaml.safe_load(handle) or {} + return payload + + +def compute_config_hash(path: str | Path | None = None) -> str: + config_path = Path(path) if path else DEFAULT_CONFIG_PATH + digest = hashlib.sha256() + with config_path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def validate_template_config(config: dict[str, Any], *, base_dir: str | Path | None = None) -> list[str]: + errors: list[str] = [] + root = Path(base_dir) if base_dir else Path(settings.BASE_DIR) + + version = config.get("version") + if not version: + errors.append("模板配置缺少 version。") + + source_dir_value = config.get("source_dir") + source_dir = root / source_dir_value if source_dir_value else None + if not source_dir_value: + errors.append("模板配置缺少 source_dir。") + elif not source_dir.exists(): + errors.append(f"模板 source_dir 不存在:{source_dir_value}") + + templates = config.get("templates") + if not isinstance(templates, list) or not templates: + errors.append("模板配置必须包含非空 templates 列表。") + return errors + + seen_codes: set[str] = set() + for index, template in enumerate(templates, start=1): + if not isinstance(template, dict): + errors.append(f"第 {index} 个模板配置必须是对象。") + continue + code = str(template.get("code") or "").strip() + if not code: + errors.append(f"第 {index} 个模板缺少 code。") + elif code in seen_codes: + errors.append(f"模板 code 重复:{code}") + seen_codes.add(code) + + file_format = str(template.get("file_format") or "").strip().lower() + if file_format not in SUPPORTED_FILE_FORMATS: + errors.append(f"模板 {code or index} 的 file_format 不支持:{file_format or '空'}") + + source_file = str(template.get("source_file") or "").strip() + if not source_file: + errors.append(f"模板 {code or index} 缺少 source_file。") + elif source_dir and source_dir.exists() and not (source_dir / source_file).exists(): + errors.append(f"模板 {code or index} 的 source_file 不存在:{source_file}") + + fields = template.get("fields") or [] + if not isinstance(fields, list): + errors.append(f"模板 {code or index} 的 fields 必须是列表。") + continue + for field_index, field in enumerate(fields, start=1): + target = field.get("target") if isinstance(field, dict) else None + target_type = str((target or {}).get("type") or "").strip() + if target_type not in SUPPORTED_TARGET_TYPES: + errors.append( + f"模板 {code or index} 第 {field_index} 个字段 target.type 不支持:{target_type or '空'}" + ) + return errors + + +def template_specs(config: dict[str, Any]) -> list[dict[str, Any]]: + return list(config.get("templates") or []) diff --git a/review_agent/application_form_fill/services/template_repository.py b/review_agent/application_form_fill/services/template_repository.py new file mode 100644 index 0000000..0b9f691 --- /dev/null +++ b/review_agent/application_form_fill/services/template_repository.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from django.conf import settings + +from review_agent.application_form_fill.schemas import TemplateSpec +from review_agent.application_form_fill.storage import create_artifact_for_file, ensure_batch_subdir +from review_agent.models import ApplicationFormFillArtifact, ApplicationFormFillBatch + + +class TemplateUnavailableError(Exception): + pass + + +def resolve_source_template(spec: TemplateSpec, config: dict[str, Any]) -> Path: + source_dir = Path(settings.BASE_DIR) / str(config.get("source_dir") or "") + working_template = getattr(spec, "working_template", "") or "" + if spec.file_format == "doc" and working_template: + candidate = source_dir / working_template + else: + candidate = source_dir / spec.source_file + if not candidate.exists(): + raise TemplateUnavailableError(f"模板文件不存在:{spec.source_file}") + if spec.file_format == "doc" and candidate.suffix.lower() == ".doc": + raise TemplateUnavailableError(f"模板 {spec.code} 为 .doc,当前阶段需预转换为 .docx 后使用。") + return candidate + + +def copy_template_to_batch( + spec: TemplateSpec, + batch: ApplicationFormFillBatch, + config: dict[str, Any], +) -> ApplicationFormFillArtifact: + source = resolve_source_template(spec, config) + target_dir = ensure_batch_subdir(batch, "templates") + target = target_dir / f"{spec.code}.source{source.suffix.lower()}" + shutil.copy2(source, target) + _ensure_under(target, Path(batch.work_dir)) + return create_artifact_for_file( + batch, + path=target, + artifact_type=ApplicationFormFillArtifact.ArtifactType.TEMPLATE_COPY, + file_format=source.suffix.lower().lstrip(".") or spec.file_format, + name=spec.name, + metadata={"template_code": spec.code, "source_file": spec.source_file}, + created_by_node="template_copy", + ) + + +def _ensure_under(path: Path, root: Path) -> None: + resolved_path = path.resolve() + resolved_root = root.resolve() + if resolved_path != resolved_root and resolved_root not in resolved_path.parents: + raise ValueError(f"模板复制目标不在批次工作目录内:{path}") diff --git a/review_agent/application_form_fill/services/template_select.py b/review_agent/application_form_fill/services/template_select.py new file mode 100644 index 0000000..11c770d --- /dev/null +++ b/review_agent/application_form_fill/services/template_select.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from typing import Any + +from review_agent.application_form_fill.constants import ( + TEMPLATE_CHANGE_REGISTRATION, + TEMPLATE_ESSENTIAL_PRINCIPLES, + TEMPLATE_REGISTRATION_CERTIFICATE, +) +from review_agent.application_form_fill.schemas import TemplateSpec +from review_agent.models import ApplicationFormFillBatch + + +ALL_TEMPLATE_CODES = [ + TEMPLATE_REGISTRATION_CERTIFICATE, + TEMPLATE_CHANGE_REGISTRATION, + TEMPLATE_ESSENTIAL_PRINCIPLES, +] + + +def parse_requested_templates(message: str) -> list[str]: + normalized = (message or "").lower() + if any(keyword in normalized for keyword in ["全部模板", "所有模板", "全套模板", "全部表格", "所有表格"]): + return ALL_TEMPLATE_CODES.copy() + + requested: list[str] = [] + if "注册证" in normalized and "变更注册" not in normalized and "变更 注册" not in normalized: + requested.append(TEMPLATE_REGISTRATION_CERTIFICATE) + if any(keyword in normalized for keyword in ["变更注册", "变更 注册", "变更备案", "备案文件"]): + requested.append(TEMPLATE_CHANGE_REGISTRATION) + if any(keyword in normalized for keyword in ["安全和性能基本原则", "基本原则清单", "原则清单"]): + requested.append(TEMPLATE_ESSENTIAL_PRINCIPLES) + return _dedupe(requested) + + +def detect_registration_type( + *, + batch: ApplicationFormFillBatch | None = None, + message: str = "", + file_candidates: dict[str, Any] | None = None, +) -> tuple[str, str]: + user_value = _registration_type_from_text(message) + if user_value: + return user_value, ApplicationFormFillBatch.RegistrationTypeSource.USER_MESSAGE + + regulatory_value = _registration_type_from_regulatory_batch(batch) + if regulatory_value: + return regulatory_value, ApplicationFormFillBatch.RegistrationTypeSource.REGULATORY_BATCH + + file_value = _registration_type_from_candidates(file_candidates or {}) + if file_value: + return file_value, ApplicationFormFillBatch.RegistrationTypeSource.FILE_EXTRACT + + return "unknown", ApplicationFormFillBatch.RegistrationTypeSource.UNKNOWN + + +def select_templates( + config: dict[str, Any], + requested_templates: list[str], + registration_type: str, +) -> tuple[list[TemplateSpec], list[dict[str, str]]]: + template_map = {item.get("code"): item for item in config.get("templates") or []} + risk_notes: list[dict[str, str]] = [] + if requested_templates: + selected_codes = _dedupe(requested_templates) + elif registration_type in {"变更注册", "备案"}: + selected_codes = [TEMPLATE_CHANGE_REGISTRATION, TEMPLATE_ESSENTIAL_PRINCIPLES] + else: + selected_codes = [TEMPLATE_REGISTRATION_CERTIFICATE, TEMPLATE_ESSENTIAL_PRINCIPLES] + + specs: list[TemplateSpec] = [] + for code in selected_codes: + raw = template_map.get(code) + if not raw: + risk_notes.append({"type": "unknown_template", "message": f"模板不存在:{code}"}) + continue + spec = _to_template_spec(raw) + if requested_templates and not _template_applies(spec, registration_type): + risk_notes.append( + { + "type": "template_registration_mismatch", + "message": f"用户指定模板 {spec.name} 与注册类型 {registration_type or 'unknown'} 可能不匹配,仍按指定生成。", + } + ) + specs.append(spec) + return specs, risk_notes + + +def _to_template_spec(raw: dict[str, Any]) -> TemplateSpec: + return TemplateSpec( + code=str(raw.get("code") or ""), + name=str(raw.get("name") or ""), + source_file=str(raw.get("source_file") or ""), + output_label=str(raw.get("output_label") or raw.get("name") or ""), + applies_when=dict(raw.get("applies_when") or {}), + file_format=str(raw.get("file_format") or ""), + fields=list(raw.get("fields") or []), + checklist_items=list(raw.get("checklist_items") or []), + ) + + +def _template_applies(spec: TemplateSpec, registration_type: str) -> bool: + allowed = spec.applies_when.get("registration_type") or [] + if not allowed: + return True + return registration_type in allowed or (registration_type == "unknown" and "unknown" in allowed) + + +def _registration_type_from_text(message: str) -> str: + normalized = (message or "").lower() + if any(keyword in normalized for keyword in ["首次注册", "初次注册", "新注册"]): + return "首次注册" + if "变更注册" in normalized: + return "变更注册" + if "备案" in normalized: + return "备案" + return "" + + +def _registration_type_from_regulatory_batch(batch: ApplicationFormFillBatch | None) -> str: + if not batch or not batch.source_regulatory_batch_id: + return "" + condition_json = batch.source_regulatory_batch.condition_json or {} + confirmed = condition_json.get("confirmed_conditions") or {} + candidates = condition_json.get("candidates") or {} + for payload in [confirmed, condition_json, candidates.get("registration_type") or {}]: + if isinstance(payload, dict): + value = payload.get("registration_type") or payload.get("suggested") or payload.get("value") + normalized = _normalize_registration_type(value) + if normalized: + return normalized + return "" + + +def _registration_type_from_candidates(candidates: dict[str, Any]) -> str: + value = candidates.get("registration_type") or candidates.get("suggested") + if isinstance(value, dict): + value = value.get("value") or value.get("suggested") + return _normalize_registration_type(value) + + +def _normalize_registration_type(value: Any) -> str: + text = str(value or "") + if "首次" in text or "初次" in text: + return "首次注册" + if "变更" in text: + return "变更注册" + if "备案" in text: + return "备案" + return "" + + +def _dedupe(values: list[str]) -> list[str]: + result: list[str] = [] + for value in values: + if value and value not in result: + result.append(value) + return result diff --git a/review_agent/application_form_fill/services/traceability_export.py b/review_agent/application_form_fill/services/traceability_export.py new file mode 100644 index 0000000..4be7934 --- /dev/null +++ b/review_agent/application_form_fill/services/traceability_export.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import json +from dataclasses import asdict +from pathlib import Path +from typing import Any + +from openpyxl import Workbook + +from review_agent.application_form_fill.constants import WORKFLOW_TYPE +from review_agent.application_form_fill.schemas import MergedField, TemplateSpec +from review_agent.application_form_fill.storage import create_artifact_for_file, ensure_batch_subdir +from review_agent.models import ApplicationFormFillArtifact, ApplicationFormFillBatch, ExportedSummaryFile + + +def build_traceability_workbook( + batch: ApplicationFormFillBatch, + merged_fields: dict[str, MergedField], + conflicts: list[dict[str, Any]], + specs: list[TemplateSpec], + generation_results: list[dict[str, Any]] | None = None, +) -> Workbook: + workbook = Workbook() + field_sheet = workbook.active + field_sheet.title = "字段追溯" + field_sheet.append(["模板", "字段", "填入值", "来源文件", "证据", "冲突状态"]) + template_names = {field.get("key"): spec.output_label for spec in specs for field in spec.fields} + for key, field in merged_fields.items(): + field_sheet.append( + [ + template_names.get(key, ""), + field.label, + field.value, + field.source_file, + field.evidence, + "冲突" if field.has_conflict else "一致", + ] + ) + + conflict_sheet = workbook.create_sheet("冲突字段") + conflict_sheet.append(["字段", "采用值", "冲突值", "冲突来源", "处理方式"]) + for conflict in conflicts: + conflict_values = conflict.get("conflict_values") or [] + if not conflict_values: + conflict_sheet.append( + [ + conflict.get("field_label", ""), + conflict.get("selected_value", ""), + "", + "", + conflict.get("handling", ""), + ] + ) + continue + for value in conflict_values: + conflict_sheet.append( + [ + conflict.get("field_label", ""), + conflict.get("selected_value", ""), + value.get("value", ""), + value.get("source_file", ""), + conflict.get("handling", ""), + ] + ) + + low_confidence_sheet = workbook.create_sheet("低置信度条目") + low_confidence_sheet.append(["字段", "填入值", "置信度", "来源文件"]) + for field in merged_fields.values(): + if field.confidence < 0.6: + low_confidence_sheet.append([field.label, field.value, field.confidence, field.source_file]) + + result_sheet = workbook.create_sheet("生成结果") + result_sheet.append(["模板", "Word状态", "PDF状态", "错误说明"]) + for result in generation_results or []: + result_sheet.append( + [ + result.get("template_label", ""), + result.get("word_status", ""), + result.get("pdf_status", "待增强"), + result.get("error_message", ""), + ] + ) + if not generation_results: + for spec in specs: + result_sheet.append([spec.output_label, "待生成", "待增强", ""]) + return workbook + + +def save_traceability_exports( + batch: ApplicationFormFillBatch, + merged_fields: dict[str, MergedField], + conflicts: list[dict[str, Any]], + specs: list[TemplateSpec], + generation_results: list[dict[str, Any]] | None = None, +) -> list[ExportedSummaryFile]: + target_dir = ensure_batch_subdir(batch, "exports") + workbook = build_traceability_workbook(batch, merged_fields, conflicts, specs, generation_results) + excel_path = target_dir / f"{batch.batch_no}-字段来源追溯清单.xlsx" + workbook.save(excel_path) + create_artifact_for_file( + batch, + path=excel_path, + artifact_type=ApplicationFormFillArtifact.ArtifactType.TRACEABILITY, + file_format=ApplicationFormFillArtifact.FileFormat.EXCEL, + name="字段来源追溯清单", + metadata={"conflict_count": len(conflicts)}, + created_by_node="trace_export", + ) + excel_export = ExportedSummaryFile.objects.create( + batch=batch.source_summary_batch, + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=batch.pk, + export_category="traceability", + export_type=ExportedSummaryFile.ExportType.EXCEL, + file_name=excel_path.name, + storage_path=str(excel_path), + ) + + json_path = target_dir / "merged_fields.json" + payload = { + "batch_no": batch.batch_no, + "merged_fields": {key: asdict(value) for key, value in merged_fields.items()}, + "conflicts": conflicts, + "generation_results": generation_results or [], + } + json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + create_artifact_for_file( + batch, + path=json_path, + artifact_type=ApplicationFormFillArtifact.ArtifactType.MERGED_FIELDS, + file_format=ApplicationFormFillArtifact.FileFormat.JSON, + name="merged_fields", + metadata={"conflict_count": len(conflicts)}, + created_by_node="trace_export", + ) + json_export = ExportedSummaryFile.objects.create( + batch=batch.source_summary_batch, + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=batch.pk, + export_category="traceability", + export_type=ExportedSummaryFile.ExportType.JSON, + file_name=json_path.name, + storage_path=str(json_path), + ) + return [excel_export, json_export] diff --git a/review_agent/application_form_fill/services/word_fill.py b/review_agent/application_form_fill/services/word_fill.py new file mode 100644 index 0000000..9a6e11a --- /dev/null +++ b/review_agent/application_form_fill/services/word_fill.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from docx import Document +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from docx.shared import RGBColor + +from review_agent.application_form_fill.constants import WORKFLOW_TYPE +from review_agent.application_form_fill.schemas import MergedField, TemplateSpec +from review_agent.application_form_fill.storage import create_artifact_for_file, ensure_batch_subdir +from review_agent.models import ApplicationFormFillArtifact, ApplicationFormFillBatch, ExportedSummaryFile + + +def fill_template( + template_path: str | Path, + output_path: str | Path, + spec: TemplateSpec, + fields: dict[str, MergedField], + conflicts: list[dict] | None = None, +) -> Path: + document = Document(str(template_path)) + remove_fill_instructions(document) + conflict_keys = {item.get("field_key") for item in conflicts or []} + for field_config in spec.fields: + target = field_config.get("target") or {} + if target.get("type") != "table_row": + continue + key = field_config.get("key") + field = fields.get(key) + if not field: + continue + fill_table_row( + document, + str(target.get("row_label") or field_config.get("label") or ""), + field.value, + conflict=key in conflict_keys or field.has_conflict, + ) + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + document.save(str(output)) + return output + + +def remove_fill_instructions(document: Document) -> None: + removing = False + for paragraph in list(document.paragraphs): + text = _normalize_label(paragraph.text) + if text == "填表说明": + removing = True + if removing: + _remove_paragraph(paragraph) + continue + if text.startswith("注填表前") and "填表说明" in text: + _remove_paragraph(paragraph) + + for table in document.tables: + for row in list(table.rows): + row_text = _normalize_label("".join(cell.text for cell in row.cells)) + if row_text == "填表说明" or row_text.startswith("注填表前"): + _remove_row(row) + + +def fill_table_row(document: Document, row_label: str, value: str, *, conflict: bool = False) -> bool: + normalized_label = _normalize_label(row_label) + for table in document.tables: + for row in table.rows: + if len(row.cells) < 2: + continue + if _normalize_label(row.cells[0].text) != normalized_label: + continue + target = row.cells[1] + target.text = "" + paragraph = target.paragraphs[0] + run = paragraph.add_run(value) + if conflict: + run.font.color.rgb = RGBColor(0xFF, 0x00, 0x00) + apply_cell_shading(target, "FFFF00") + return True + return False + + +def apply_cell_shading(cell, fill: str) -> None: + tc_pr = cell._tc.get_or_add_tcPr() + shading = tc_pr.find(qn("w:shd")) + if shading is None: + shading = OxmlElement("w:shd") + tc_pr.append(shading) + shading.set(qn("w:fill"), fill) + + +def _remove_paragraph(paragraph) -> None: + element = paragraph._element + element.getparent().remove(element) + + +def _remove_row(row) -> None: + row._tr.getparent().remove(row._tr) + + +def create_word_export( + batch: ApplicationFormFillBatch, + spec: TemplateSpec, + template_path: str | Path, + fields: dict[str, MergedField], + conflicts: list[dict] | None = None, +) -> ExportedSummaryFile: + target_dir = ensure_batch_subdir(batch, "filled") + product_name = _safe_filename(batch.product_name or fields.get("product_name", MergedField("product_name", "产品名称", "", "", "", 0)).value or "未识别产品") + output_path = target_dir / f"{batch.batch_no}-{product_name}-{_safe_filename(spec.output_label)}.docx" + fill_template(template_path, output_path, spec, fields, conflicts) + create_artifact_for_file( + batch, + path=output_path, + artifact_type=ApplicationFormFillArtifact.ArtifactType.FILLED_TEMPLATE, + file_format=ApplicationFormFillArtifact.FileFormat.DOCX, + name=spec.output_label, + metadata={"template_code": spec.code, "conflict_count": len(conflicts or [])}, + created_by_node="word_fill", + ) + return ExportedSummaryFile.objects.create( + batch=batch.source_summary_batch, + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=batch.pk, + export_category="filled_template", + export_type=ExportedSummaryFile.ExportType.WORD, + file_name=output_path.name, + storage_path=str(output_path), + ) + + +def _normalize_label(value: str) -> str: + return re.sub(r"\s+", "", value or "").replace(":", "").replace(":", "") + + +def _safe_filename(value: str) -> str: + text = re.sub(r"[\x00-\x1f\x7f]+", "", value or "") + text = re.sub(r'[\\/:*?"<>|]+', "_", text) + return text.strip()[:80] or "output" diff --git a/review_agent/application_form_fill/storage.py b/review_agent/application_form_fill/storage.py new file mode 100644 index 0000000..eeba562 --- /dev/null +++ b/review_agent/application_form_fill/storage.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path + +from django.conf import settings + +from review_agent.models import ApplicationFormFillArtifact, ApplicationFormFillBatch + + +def build_batch_work_dir(batch: ApplicationFormFillBatch | None = None, *, batch_no: str = "") -> Path: + if batch: + return Path(settings.MEDIA_ROOT) / "application_form_fill" / str(batch.user_id) / str(batch.conversation_id) / batch.batch_no + return Path(settings.MEDIA_ROOT) / "application_form_fill" / batch_no + + +def compute_file_sha256(path: str | Path) -> str: + file_path = Path(path) + digest = hashlib.sha256() + with file_path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def ensure_batch_subdir(batch: ApplicationFormFillBatch, name: str) -> Path: + root = Path(batch.work_dir) if batch.work_dir else build_batch_work_dir(batch) + target = root / Path(name).name + target.mkdir(parents=True, exist_ok=True) + return target + + +def create_artifact_for_file( + batch: ApplicationFormFillBatch, + *, + path: str | Path, + artifact_type: str, + file_format: str, + name: str = "", + metadata: dict | None = None, + created_by_node: str = "", +) -> ApplicationFormFillArtifact: + file_path = Path(path) + return ApplicationFormFillArtifact.objects.create( + batch=batch, + artifact_type=artifact_type, + file_format=file_format, + name=name or file_path.stem, + file_name=file_path.name, + storage_path=str(file_path), + file_size=file_path.stat().st_size if file_path.exists() else 0, + content_hash=compute_file_sha256(file_path) if file_path.exists() else "", + metadata=metadata or {}, + created_by_node=created_by_node, + ) diff --git a/review_agent/application_form_fill/templates/application_form_templates_v1.yaml b/review_agent/application_form_fill/templates/application_form_templates_v1.yaml new file mode 100644 index 0000000..75ef0a5 --- /dev/null +++ b/review_agent/application_form_fill/templates/application_form_templates_v1.yaml @@ -0,0 +1,130 @@ +version: application_form_templates_v1 +source_dir: docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告 +templates: + - code: registration_certificate + name: 中华人民共和国医疗器械注册证(体外诊断试剂)(格式) + source_file: 中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx + output_label: 注册证格式 + applies_when: + registration_type: + - 首次注册 + - unknown + file_format: docx + fields: + - key: applicant_name + label: 注册人名称 + target: + type: table_row + row_label: 注册人名称 + source_roles: + - 申请表 + - 说明书 + - 企业信息 + - key: applicant_address + label: 注册人住所 + target: + type: table_row + row_label: 注册人住所 + source_roles: + - 申请表 + - 企业信息 + - key: manufacturer_address + label: 生产地址 + target: + type: table_row + row_label: 生产地址 + source_roles: + - 申请表 + - 质量管理体系文件 + - key: agent_name + label: 代理人名称 + target: + type: table_row + row_label: 代理人名称 + source_roles: + - 说明书 + - 企业信息 + - 申请表 + - key: agent_address + label: 代理人住所 + target: + type: table_row + row_label: 代理人住所 + source_roles: + - 说明书 + - 企业信息 + - 申请表 + - key: product_name + label: 产品名称 + target: + type: table_row + row_label: 产品名称 + source_roles: + - 说明书 + - 产品技术要求 + - 注册检验报告 + - key: package_specification + label: 包装规格 + target: + type: table_row + row_label: 包装规格 + source_roles: + - 说明书 + - 产品技术要求 + - key: main_components + label: 主要组成成分 + target: + type: table_row + row_label: 主要组成成分 + source_roles: + - 说明书 + - 产品技术要求 + - key: intended_use + label: 预期用途 + target: + type: table_row + row_label: 预期用途 + source_roles: + - 说明书 + - 临床评价资料 + - 产品技术要求 + - key: storage_condition_and_validity + label: 产品储存条件及有效期 + target: + type: table_row + row_label: 产品储存条件及有效期 + source_roles: + - 说明书 + - 产品技术要求 + - 稳定性研究资料 + - key: attachments + label: 附件 + target: + type: table_row + row_label: 附件 + source_roles: + - 注册申报资料 + - 说明书 + - code: change_registration + name: 中华人民共和国医疗器械变更注册(备案)文件(体外诊断试剂)(格式) + source_file: 中华人民共和国医疗器械变更注册(备案)文件(体外诊断试剂)(格式).doc + output_label: 变更注册备案文件 + applies_when: + registration_type: + - 变更注册 + - 备案 + file_format: doc + fields: [] + - code: essential_principles + name: 体外诊断试剂安全和性能基本原则清单 + source_file: 体外诊断试剂安全和性能基本原则清单.doc + output_label: 安全和性能基本原则清单 + applies_when: + registration_type: + - 首次注册 + - 变更注册 + - 备案 + - unknown + file_format: doc + fields: [] + checklist_items: [] diff --git a/review_agent/application_form_fill/views.py b/review_agent/application_form_fill/views.py new file mode 100644 index 0000000..70879ff --- /dev/null +++ b/review_agent/application_form_fill/views.py @@ -0,0 +1,131 @@ +import json + +from django.contrib.auth.decorators import login_required +from django.conf import settings +from django.http import Http404, JsonResponse +from django.views.decorators.http import require_http_methods + +from review_agent.application_form_fill.workflow import ( + create_application_form_fill_batch, + find_latest_successful_summary_batch, + start_application_form_fill_workflow, +) +from review_agent.models import ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileSummaryBatch, WorkflowNodeRun +from review_agent.notifications.presenter import serialize_notification_records + + +@require_http_methods(["GET"]) +def health(request): + return JsonResponse({"workflow_type": "application_form_fill", "status": "available"}) + + +@login_required +@require_http_methods(["POST"]) +def start(request): + try: + payload = json.loads(request.body.decode("utf-8") or "{}") + except json.JSONDecodeError: + return JsonResponse({"error": "JSON 格式错误。"}, status=400) + + conversation = Conversation.objects.filter(pk=payload.get("conversation_id"), user=request.user).first() + if not conversation: + raise Http404("对话不存在。") + + summary_batch = None + if payload.get("file_summary_batch_id"): + summary_batch = FileSummaryBatch.objects.filter( + pk=payload.get("file_summary_batch_id"), + conversation=conversation, + user=request.user, + status=FileSummaryBatch.Status.SUCCESS, + ).first() + if summary_batch is None: + summary_batch = find_latest_successful_summary_batch(conversation) + if summary_batch is None: + return JsonResponse({"error": "请先上传资料并完成文件汇总。"}, status=400) + + batch = create_application_form_fill_batch( + conversation=conversation, + user=request.user, + source_summary_batch=summary_batch, + requested_templates=payload.get("template_codes") or [], + output_types=payload.get("output_types") or None, + ) + start_application_form_fill_workflow(batch, async_run=getattr(settings, "APPLICATION_FORM_FILL_ASYNC", True)) + return JsonResponse( + { + "batch_id": batch.pk, + "workflow_type": "application_form_fill", + "status": batch.status, + "selected_templates": batch.selected_templates, + } + ) + + +@login_required +@require_http_methods(["GET"]) +def batch_status(request, batch_id: int): + batch = ApplicationFormFillBatch.objects.filter( + pk=batch_id, + conversation__user=request.user, + is_deleted=False, + ).first() + if not batch: + raise Http404("填表批次不存在。") + exports = ExportedSummaryFile.objects.filter( + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + ).order_by("id") + notifications = serialize_notification_records("application_form_fill", batch.pk) + return JsonResponse( + { + "batch": { + "id": batch.pk, + "workflow_type": "application_form_fill", + "batch_no": batch.batch_no, + "status": batch.status, + "product_name": batch.product_name, + "selected_templates": batch.selected_templates, + "conflict_count": len(batch.conflict_summary or []), + "risk_summary_text": _risk_summary_text(batch), + "error_message": batch.error_message, + }, + "nodes": [ + { + "node_code": node.node_code, + "node_name": node.node_name, + "status": node.status, + "progress": node.progress, + "message": node.message, + } + for node in WorkflowNodeRun.objects.filter( + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + ).order_by("id") + ], + "conflicts": batch.conflict_summary or [], + "exports": [ + { + "id": export.pk, + "export_type": export.export_type, + "export_category": export.export_category, + "file_name": export.file_name, + "download_url": f"/api/review-agent/file-summary/exports/{export.pk}/download/", + } + for export in exports + ], + "notifications": notifications, + "latest_notification": notifications[0] if notifications else None, + } + ) + + +def _risk_summary_text(batch: ApplicationFormFillBatch) -> str: + parts = [] + if batch.selected_templates: + parts.append("模板 " + "、".join(batch.selected_templates)) + if batch.conflict_summary: + parts.append(f"冲突字段 {len(batch.conflict_summary)}") + if batch.risk_notes: + parts.append(f"提示 {len(batch.risk_notes)}") + return " · ".join(parts) diff --git a/review_agent/application_form_fill/workflow.py b/review_agent/application_form_fill/workflow.py new file mode 100644 index 0000000..57699d3 --- /dev/null +++ b/review_agent/application_form_fill/workflow.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +import logging +from threading import Thread +from uuid import uuid4 + +from django.conf import settings +from django.db import transaction +from django.utils import timezone + +from review_agent.application_form_fill.constants import DEFAULT_OUTPUT_TYPES, FORM_FILL_NODE_DEFINITIONS, WORKFLOW_TYPE +from review_agent.application_form_fill.events import record_event +from review_agent.application_form_fill.services.field_extract import ( + collect_document_texts, + run_parallel_extract, + save_field_extract_result, +) +from review_agent.application_form_fill.services.field_merge import merge_fields +from review_agent.application_form_fill.services.notifier import notify_completion +from review_agent.application_form_fill.services.summary import build_assistant_summary +from review_agent.application_form_fill.services.template_config import ( + compute_config_hash, + load_template_config, + validate_template_config, +) +from review_agent.application_form_fill.services.template_repository import ( + TemplateUnavailableError, + copy_template_to_batch, +) +from review_agent.application_form_fill.services.template_select import ( + detect_registration_type, + parse_requested_templates, + select_templates, +) +from review_agent.application_form_fill.services.traceability_export import save_traceability_exports +from review_agent.application_form_fill.services.word_fill import create_word_export +from review_agent.application_form_fill.schemas import MergedField, TemplateSpec +from review_agent.application_form_fill.storage import build_batch_work_dir +from review_agent.models import ApplicationFormFillBatch, Conversation, FileSummaryBatch, Message, WorkflowNodeRun + + +logger = logging.getLogger("review_agent.application_form_fill.workflow") + + +def build_batch_no() -> str: + return f"AFF-{timezone.localtime().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6]}" + + +def find_latest_successful_summary_batch(conversation: Conversation) -> FileSummaryBatch | None: + return ( + FileSummaryBatch.objects.filter( + conversation=conversation, + status=FileSummaryBatch.Status.SUCCESS, + ) + .order_by("-finished_at", "-created_at", "-id") + .first() + ) + + +@transaction.atomic +def create_application_form_fill_batch( + *, + conversation: Conversation, + user, + source_summary_batch: FileSummaryBatch, + trigger_message: Message | None = None, + requested_templates: list[str] | None = None, + output_types: list[str] | None = None, +) -> ApplicationFormFillBatch: + batch_no = build_batch_no() + work_dir = build_batch_work_dir(batch_no=batch_no) + work_dir.mkdir(parents=True, exist_ok=True) + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + trigger_message=trigger_message, + source_summary_batch=source_summary_batch, + batch_no=batch_no, + requested_templates=requested_templates or [], + output_types=output_types or DEFAULT_OUTPUT_TYPES, + work_dir=str(work_dir), + ) + for code, name, group in FORM_FILL_NODE_DEFINITIONS: + WorkflowNodeRun.objects.create( + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=batch.pk, + node_group=group, + node_code=code, + node_name=name, + ) + record_event(batch, "workflow_created", {"batch_id": batch.pk, "batch_no": batch.batch_no}) + return batch + + +class FormFillWorkflowExecutor: + """Runs the auto-fill workflow skeleton; later stages fill node bodies.""" + + def __init__(self, batch: ApplicationFormFillBatch): + self.batch = batch + self.template_config: dict = {} + self.selected_templates: list[TemplateSpec] = [] + self.template_paths: dict[str, str] = {} + self.document_texts: dict[str, str] = {} + self.extract_payload: dict = {} + self.merged_fields: dict[str, MergedField] = {} + self.conflicts: list[dict] = [] + self.exports = [] + self.generation_results: list[dict] = [] + self.non_blocking_errors: list[str] = [] + + def run(self) -> None: + logger.info("自动填表工作流开始 batch_no=%s batch_id=%s", self.batch.batch_no, self.batch.pk) + self.batch.status = ApplicationFormFillBatch.Status.RUNNING + self.batch.started_at = timezone.now() + self.batch.save(update_fields=["status", "started_at"]) + record_event(self.batch, "workflow_started", {"batch_id": self.batch.pk}) + + try: + for node in self._nodes(): + if node.status in {WorkflowNodeRun.Status.SUCCESS, WorkflowNodeRun.Status.SKIPPED}: + continue + self._run_node(node) + except Exception as exc: + logger.exception("Application form fill workflow failed", extra={"batch_id": self.batch.pk}) + self.batch.status = ApplicationFormFillBatch.Status.FAILED + self.batch.error_message = str(exc) + self.batch.finished_at = timezone.now() + self.batch.save(update_fields=["status", "error_message", "finished_at"]) + record_event(self.batch, "workflow_failed", {"message": str(exc)}) + return + + self.batch.refresh_from_db() + if self.batch.status != ApplicationFormFillBatch.Status.PARTIAL_SUCCESS: + self.batch.status = ApplicationFormFillBatch.Status.SUCCESS + self.batch.finished_at = timezone.now() + self.batch.save(update_fields=["status", "finished_at"]) + record_event(self.batch, "workflow_completed", {"batch_id": self.batch.pk}) + logger.info("自动填表工作流完成 batch_no=%s", self.batch.batch_no) + + def _nodes(self): + return WorkflowNodeRun.objects.filter( + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=self.batch.pk, + ).order_by("id") + + def _run_node(self, node: WorkflowNodeRun) -> None: + node.status = WorkflowNodeRun.Status.RUNNING + node.progress = 10 + node.started_at = timezone.now() + node.message = f"{node.node_name}处理中" + node.save(update_fields=["status", "progress", "started_at", "message"]) + record_event( + self.batch, + "node_progress", + {"node_code": node.node_code, "status": node.status, "progress": node.progress, "message": node.message}, + ) + + if node.node_code == "pdf_convert": + self._append_risk_note( + { + "type": "pdf_pending", + "message": "PDF 转换为后续增强项,本次优先生成 Word。", + } + ) + node.status = WorkflowNodeRun.Status.SKIPPED + node.progress = 100 + node.finished_at = timezone.now() + node.message = "PDF 转换为后续增强项,本次跳过" + node.save(update_fields=["status", "progress", "finished_at", "message"]) + record_event( + self.batch, + "node_progress", + {"node_code": node.node_code, "status": node.status, "progress": node.progress, "message": node.message}, + ) + return + + self._execute_node(node) + + node.status = WorkflowNodeRun.Status.SUCCESS + node.progress = 100 + node.finished_at = timezone.now() + node.message = f"{node.node_name}完成" + node.save(update_fields=["status", "progress", "finished_at", "message"]) + record_event( + self.batch, + "node_progress", + {"node_code": node.node_code, "status": node.status, "progress": node.progress, "message": node.message}, + ) + + def _execute_node(self, node: WorkflowNodeRun) -> None: + if node.node_code == "prepare": + if self.batch.source_summary_batch.status != FileSummaryBatch.Status.SUCCESS: + raise ValueError("自动填表需要成功的文件汇总批次。") + return + if node.node_code == "template_select": + self.template_config = load_template_config() + errors = validate_template_config(self.template_config) + if errors: + raise ValueError(";".join(errors)) + requested = parse_requested_templates(self.batch.trigger_message.content if self.batch.trigger_message else "") + registration_type, source = detect_registration_type(batch=self.batch, message=self.batch.trigger_message.content if self.batch.trigger_message else "") + specs, risk_notes = select_templates(self.template_config, requested, registration_type) + if not specs: + raise ValueError("未选择到可用申报模板。") + self.selected_templates = specs + self.batch.requested_templates = requested + self.batch.selected_templates = [spec.code for spec in specs] + self.batch.registration_type = registration_type + self.batch.registration_type_source = source + self.batch.template_config_version = str(self.template_config.get("version") or "") + self.batch.template_config_hash = compute_config_hash() + self.batch.risk_notes = list(self.batch.risk_notes or []) + risk_notes + self.batch.save( + update_fields=[ + "requested_templates", + "selected_templates", + "registration_type", + "registration_type_source", + "template_config_version", + "template_config_hash", + "risk_notes", + ] + ) + return + if node.node_code == "template_copy": + for spec in self.selected_templates: + try: + artifact = copy_template_to_batch(spec, self.batch, self.template_config) + self.template_paths[spec.code] = artifact.storage_path + except TemplateUnavailableError as exc: + self.non_blocking_errors.append(str(exc)) + self._append_risk_note({"type": "template_unavailable", "message": str(exc), "template_code": spec.code}) + if not self.template_paths: + raise ValueError("没有可用的 Word 模板副本。") + return + if node.node_code == "field_extract": + self.document_texts = collect_document_texts(self.batch.source_summary_batch) + self.extract_payload = run_parallel_extract(self.document_texts, self.selected_templates) + save_field_extract_result(self.batch, self.extract_payload) + return + if node.node_code == "conflict_merge": + self.merged_fields, self.conflicts = merge_fields( + self.extract_payload.get("regex_results") or {}, + self.extract_payload.get("llm_results") or {}, + ) + product = self.merged_fields.get("product_name") + if product and product.value: + self.batch.product_name = product.value + self.batch.conflict_summary = self.conflicts + self.batch.save(update_fields=["product_name", "conflict_summary"]) + return + if node.node_code == "word_fill": + for spec in self.selected_templates: + template_path = self.template_paths.get(spec.code) + if not template_path: + self.generation_results.append( + { + "template_code": spec.code, + "template_label": spec.output_label, + "word_status": "failed", + "pdf_status": "待增强", + "error_message": "模板不可用", + } + ) + continue + export = create_word_export(self.batch, spec, template_path, self.merged_fields, self.conflicts) + self.exports.append(export) + self.generation_results.append( + { + "template_code": spec.code, + "template_label": spec.output_label, + "word_status": "success", + "pdf_status": "待增强", + "error_message": "", + } + ) + if not any(item["word_status"] == "success" for item in self.generation_results): + raise ValueError("所有目标 Word 模板均生成失败。") + return + if node.node_code == "trace_export": + self.exports.extend( + save_traceability_exports( + self.batch, + self.merged_fields, + self.conflicts, + self.selected_templates, + self.generation_results, + ) + ) + return + if node.node_code == "output_export": + Message.objects.create( + conversation=self.batch.conversation, + role=Message.Role.ASSISTANT, + content=build_assistant_summary(self.batch, self.exports), + ) + return + if node.node_code == "notify": + notification = notify_completion( + self.batch, + self.exports, + fail=getattr(settings, "APPLICATION_FORM_FILL_MOCK_NOTIFY_FAIL", False), + ) + if notification.send_status == notification.SendStatus.FAILED: + self.non_blocking_errors.append(notification.error_message or "通知失败") + return + if node.node_code == "completed": + self._mark_final_status() + + def _mark_final_status(self) -> None: + failed_word = any(item.get("word_status") == "failed" for item in self.generation_results) + if self.non_blocking_errors or failed_word: + self.batch.status = ApplicationFormFillBatch.Status.PARTIAL_SUCCESS + else: + self.batch.status = ApplicationFormFillBatch.Status.SUCCESS + self.batch.save(update_fields=["status"]) + + def _append_risk_note(self, note: dict) -> None: + self.batch.risk_notes = list(self.batch.risk_notes or []) + [note] + self.batch.save(update_fields=["risk_notes"]) + + +def start_application_form_fill_workflow(batch: ApplicationFormFillBatch, *, async_run: bool = True) -> None: + executor = FormFillWorkflowExecutor(batch) + if not async_run: + executor.run() + return + Thread(target=executor.run, daemon=True).start() diff --git a/review_agent/apps.py b/review_agent/apps.py new file mode 100644 index 0000000..802988a --- /dev/null +++ b/review_agent/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ReviewAgentConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "review_agent" + verbose_name = "审核智能体" diff --git a/review_agent/feishu_questions/__init__.py b/review_agent/feishu_questions/__init__.py new file mode 100644 index 0000000..c83871d --- /dev/null +++ b/review_agent/feishu_questions/__init__.py @@ -0,0 +1 @@ +"""Reserved Feishu question services.""" diff --git a/review_agent/feishu_questions/intent.py b/review_agent/feishu_questions/intent.py new file mode 100644 index 0000000..d0cadd1 --- /dev/null +++ b/review_agent/feishu_questions/intent.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import re + + +WORKFLOW_KEYWORDS = { + "regulatory_review": ("法规核查", "风险", "整改", "RR-"), + "application_form_fill": ("自动填表", "填表", "申报文件", "AFF-"), + "file_summary": ("自动汇总", "文件汇总", "目录", "页数", "FS-"), +} + + +def parse_question_intent(text: str) -> dict[str, object]: + normalized = (text or "").strip() + batch_no = _extract_batch_no(normalized) + workflow_type = _detect_workflow_type(normalized, batch_no) + latest = bool(re.search(r"(最新|最近|上一个|最后一个)", normalized)) + intent = "batch_status" if batch_no or latest else "unknown" + if workflow_type == "regulatory_review" and any(keyword in normalized for keyword in ["风险", "阻断", "整改"]): + intent = "risk_summary" + if workflow_type == "application_form_fill" and any(keyword in normalized for keyword in ["导出", "文件", "word", "Word"]): + intent = "export_summary" + if workflow_type == "file_summary" and any(keyword in normalized for keyword in ["缺失", "目录", "页数"]): + intent = "missing_summary" + return { + "intent": intent, + "workflow_type": workflow_type, + "batch_no": batch_no, + "latest": latest or not batch_no, + } + + +def _extract_batch_no(text: str) -> str: + match = re.search(r"\b(?:RR|AFF|FS)-[A-Za-z0-9-]+", text, flags=re.IGNORECASE) + return match.group(0).upper() if match else "" + + +def _detect_workflow_type(text: str, batch_no: str = "") -> str: + source = f"{text} {batch_no}" + for workflow_type, keywords in WORKFLOW_KEYWORDS.items(): + if any(keyword in source for keyword in keywords): + return workflow_type + return "" diff --git a/review_agent/feishu_questions/permissions.py b/review_agent/feishu_questions/permissions.py new file mode 100644 index 0000000..a99ea63 --- /dev/null +++ b/review_agent/feishu_questions/permissions.py @@ -0,0 +1,9 @@ +from __future__ import annotations + + +def can_access_batch(user, batch) -> bool: + if not user or not getattr(user, "is_authenticated", False): + return False + if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): + return True + return getattr(batch, "user_id", None) == user.pk diff --git a/review_agent/feishu_questions/query.py b/review_agent/feishu_questions/query.py new file mode 100644 index 0000000..91f8256 --- /dev/null +++ b/review_agent/feishu_questions/query.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from review_agent.models import ApplicationFormFillBatch, ExportedSummaryFile, FileSummaryBatch, RegulatoryReviewBatch + +from .permissions import can_access_batch + + +WORKFLOW_MODELS = { + "file_summary": FileSummaryBatch, + "regulatory_review": RegulatoryReviewBatch, + "application_form_fill": ApplicationFormFillBatch, +} + + +def query_batch_summary(user, *, workflow_type: str | None = None, batch_no: str | None = None, latest: bool = False) -> dict: + candidates = _candidate_batches(workflow_type) + if batch_no: + for current_workflow_type, model in candidates: + batch = model.objects.filter(batch_no=batch_no).first() + if batch: + return _serialize_allowed_batch(user, current_workflow_type, batch) + return {"ok": False, "permission_result": "not_found", "answer_summary": "未找到对应批次。"} + + if latest: + for current_workflow_type, model in candidates: + queryset = model.objects.all().order_by("-finished_at", "-created_at", "-id") + for batch in queryset: + if can_access_batch(user, batch): + return _serialize_batch(current_workflow_type, batch, permission_result="allowed") + return {"ok": False, "permission_result": "not_found", "answer_summary": "未找到可访问的批次。"} + + return {"ok": False, "permission_result": "not_found", "answer_summary": "请提供批次号,或询问最新/最近批次。"} + + +def _candidate_batches(workflow_type: str | None): + if workflow_type and workflow_type in WORKFLOW_MODELS: + return [(workflow_type, WORKFLOW_MODELS[workflow_type])] + return list(WORKFLOW_MODELS.items()) + + +def _serialize_allowed_batch(user, workflow_type: str, batch) -> dict: + if not can_access_batch(user, batch): + return {"ok": False, "permission_result": "denied", "answer_summary": "无权限访问该批次。"} + return _serialize_batch(workflow_type, batch, permission_result="allowed") + + +def _serialize_batch(workflow_type: str, batch, *, permission_result: str) -> dict: + summary = _summary_for_batch(workflow_type, batch) + result_url = _result_url(workflow_type, batch.pk) + answer = f"{batch.batch_no} 状态 {batch.status}。{summary}" + return { + "ok": True, + "permission_result": permission_result, + "workflow_type": workflow_type, + "batch_id": batch.pk, + "batch_no": batch.batch_no, + "status": batch.status, + "summary": summary, + "result_url": result_url, + "answer_summary": answer, + } + + +def _summary_for_batch(workflow_type: str, batch) -> str: + if workflow_type == "file_summary": + return f"文件 {batch.total_files} 个,成功 {batch.success_files} 个,失败 {batch.failed_files} 个。" + if workflow_type == "regulatory_review": + risk = batch.risk_summary or {} + return f"阻断项 {int(risk.get('blocking') or 0)} 个,高风险 {int(risk.get('high') or 0)} 个。" + if workflow_type == "application_form_fill": + export_count = ExportedSummaryFile.objects.filter( + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + ).count() + return f"导出文件 {export_count} 个,冲突字段 {len(batch.conflict_summary or [])} 个。" + return "" + + +def _result_url(workflow_type: str, batch_id: int) -> str: + paths = { + "file_summary": f"/api/review-agent/file-summary/{batch_id}/status/", + "regulatory_review": f"/api/review-agent/regulatory-review/{batch_id}/status/", + "application_form_fill": f"/api/review-agent/application-form-fill/{batch_id}/status/", + } + return paths.get(workflow_type, "/") diff --git a/review_agent/feishu_questions/service.py b/review_agent/feishu_questions/service.py new file mode 100644 index 0000000..3d36d8f --- /dev/null +++ b/review_agent/feishu_questions/service.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from django.utils import timezone + +from review_agent.models import FeishuQuestionLog + +from .intent import parse_question_intent +from .query import query_batch_summary + + +def answer_question(user, text: str, *, source_type: str = FeishuQuestionLog.SourceType.SIMULATE) -> dict: + parsed = parse_question_intent(text) + result = query_batch_summary( + user, + workflow_type=parsed.get("workflow_type") or None, + batch_no=parsed.get("batch_no") or None, + latest=bool(parsed.get("latest")), + ) + status = FeishuQuestionLog.Status.SUCCESS if result.get("ok") else FeishuQuestionLog.Status.FAILED + answer_summary = str(result.get("answer_summary") or "") + log = FeishuQuestionLog.objects.create( + system_user=user if getattr(user, "is_authenticated", False) else None, + source_type=source_type, + question_text=text, + intent=str(parsed.get("intent") or "unknown"), + query_object={ + "workflow_type": parsed.get("workflow_type") or "", + "batch_no": parsed.get("batch_no") or "", + "latest": bool(parsed.get("latest")), + }, + answer_summary=answer_summary[:500], + permission_result=str(result.get("permission_result") or ""), + status=status, + error_message="" if result.get("ok") else answer_summary, + processed_at=timezone.now(), + ) + return {**result, "intent": parsed.get("intent"), "log_id": log.pk} diff --git a/review_agent/file_summary/__init__.py b/review_agent/file_summary/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/review_agent/file_summary/__init__.py @@ -0,0 +1 @@ + diff --git a/review_agent/file_summary/constants.py b/review_agent/file_summary/constants.py new file mode 100644 index 0000000..3421ec9 --- /dev/null +++ b/review_agent/file_summary/constants.py @@ -0,0 +1,4 @@ +from pathlib import Path + + +ATTACHMENT_ROOT = Path("file_summary") / "users" diff --git a/review_agent/file_summary/events.py b/review_agent/file_summary/events.py new file mode 100644 index 0000000..384f17c --- /dev/null +++ b/review_agent/file_summary/events.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from review_agent.models import FileSummaryBatch, WorkflowEvent + + +def record_event(batch: FileSummaryBatch, event_type: str, payload: dict | None = None) -> WorkflowEvent: + return WorkflowEvent.objects.create( + batch=batch, + workflow_type="file_summary", + workflow_batch_id=batch.pk, + conversation=batch.conversation, + event_type=event_type, + payload=payload or {}, + ) + + +def serialize_event(event: WorkflowEvent) -> dict[str, object]: + return { + "id": event.pk, + "event_type": event.event_type, + "payload": event.payload, + "created_at": event.created_at.isoformat(), + } diff --git a/review_agent/file_summary/paths.py b/review_agent/file_summary/paths.py new file mode 100644 index 0000000..8735825 --- /dev/null +++ b/review_agent/file_summary/paths.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from pathlib import Path + +from django.conf import settings + + +def resolve_storage_path(storage_path: str) -> Path: + path = Path(storage_path) + if path.is_absolute(): + return path + return Path(settings.MEDIA_ROOT) / path diff --git a/review_agent/file_summary/services/__init__.py b/review_agent/file_summary/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/review_agent/file_summary/services/__init__.py @@ -0,0 +1 @@ + diff --git a/review_agent/file_summary/services/archive.py b/review_agent/file_summary/services/archive.py new file mode 100644 index 0000000..531336b --- /dev/null +++ b/review_agent/file_summary/services/archive.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import logging +import subprocess +from pathlib import Path +from zipfile import ZipFile + +import py7zr + + +ARCHIVE_EXTENSIONS = {"zip", "7z", "rar"} + +logger = logging.getLogger("review_agent.file_summary.services.archive") + + +def _ensure_inside_target(path: Path, target_dir: Path) -> None: + target = target_dir.resolve() + resolved = path.resolve() + if target != resolved and target not in resolved.parents: + raise ValueError("解压路径必须位于批次工作目录内。") + + +def _safe_member_path(target_dir: Path, member_name: str) -> Path: + destination = target_dir / member_name + _ensure_inside_target(destination, target_dir) + return destination + + +def extract_archive(archive_path: str | Path, target_dir: str | Path) -> list[Path]: + archive_path = Path(archive_path) + target_dir = Path(target_dir) + target_dir.mkdir(parents=True, exist_ok=True) + ext = archive_path.suffix.lower().lstrip(".") + if ext not in ARCHIVE_EXTENSIONS: + return [] + + if ext == "zip": + return _extract_zip(archive_path, target_dir) + if ext == "7z": + return _extract_7z(archive_path, target_dir) + return _extract_rar(archive_path, target_dir) + + +def _extract_zip(archive_path: Path, target_dir: Path) -> list[Path]: + extracted: list[Path] = [] + with ZipFile(archive_path) as archive: + for member in archive.infolist(): + destination = _safe_member_path(target_dir, member.filename) + if member.is_dir(): + destination.mkdir(parents=True, exist_ok=True) + continue + destination.parent.mkdir(parents=True, exist_ok=True) + with archive.open(member) as source, destination.open("wb") as target: + target.write(source.read()) + extracted.append(destination) + return extracted + + +def _extract_7z(archive_path: Path, target_dir: Path) -> list[Path]: + with py7zr.SevenZipFile(archive_path, mode="r") as archive: + names = archive.getnames() + for name in names: + _safe_member_path(target_dir, name) + archive.extractall(path=target_dir) + return [target_dir / name for name in names if (target_dir / name).is_file()] + + +def _extract_rar(archive_path: Path, target_dir: Path) -> list[Path]: + try: + extracted = _extract_rar_with_libarchive(archive_path, target_dir) + except Exception as exc: + logger.warning( + "RAR libarchive extract failed, falling back to 7z", + extra={"archive_path": str(archive_path), "target_dir": str(target_dir), "error": str(exc)}, + ) + else: + if extracted: + return extracted + logger.info( + "RAR libarchive extract produced no files, falling back to 7z", + extra={"archive_path": str(archive_path), "target_dir": str(target_dir)}, + ) + return _extract_rar_with_7z(archive_path, target_dir) + + +def _extract_rar_with_libarchive(archive_path: Path, target_dir: Path) -> list[Path]: + try: + import libarchive + except ImportError as exc: + raise RuntimeError("未安装 libarchive,跳过 Python RAR 解压。") from exc + + extracted: list[Path] = [] + with libarchive.file_reader(str(archive_path)) as entries: + for entry in entries: + destination = _safe_member_path(target_dir, entry.pathname) + if entry.isdir: + destination.mkdir(parents=True, exist_ok=True) + continue + if not entry.isfile: + logger.info( + "RAR libarchive skipped non-regular entry", + extra={"archive_path": str(archive_path), "entry": entry.pathname}, + ) + continue + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("wb") as target: + for block in entry.get_blocks(): + target.write(block) + extracted.append(destination) + return extracted + + +def _extract_rar_with_7z(archive_path: Path, target_dir: Path) -> list[Path]: + result = subprocess.run( + ["7z", "x", f"-o{target_dir}", str(archive_path), "-y"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr or result.stdout or "rar 解压失败") + extracted = [path for path in target_dir.rglob("*") if path.is_file()] + for path in extracted: + _ensure_inside_target(path, target_dir) + return extracted diff --git a/review_agent/file_summary/services/attachment_reader.py b/review_agent/file_summary/services/attachment_reader.py new file mode 100644 index 0000000..c8b5c69 --- /dev/null +++ b/review_agent/file_summary/services/attachment_reader.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import csv +import logging +from tempfile import TemporaryDirectory +from dataclasses import asdict, dataclass, field +from pathlib import Path + +from django.conf import settings + +from review_agent.models import FileAttachment +from review_agent.file_summary.services.archive import ARCHIVE_EXTENSIONS, extract_archive + + +TEXT_EXTENSIONS = {"txt", "md", "csv", "json", "log"} +SUPPORTED_EXTENSIONS = TEXT_EXTENSIONS | {"pdf", "docx", "xlsx", "pptx"} | ARCHIVE_EXTENSIONS +MAX_PREVIEW_CHARS = 3000 +MAX_ROWS_PER_SHEET = 20 + + +logger = logging.getLogger("review_agent.file_summary.attachment_reader") + + +@dataclass(frozen=True) +class AttachmentReadResult: + status: str + filename: str + file_type: str + file_size: int + preview_text: str = "" + sections: list[dict[str, object]] = field(default_factory=list) + error_message: str = "" + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +def read_attachment_details(attachment: FileAttachment) -> AttachmentReadResult: + file_path = _attachment_absolute_path(attachment) + file_type = Path(attachment.original_name).suffix.lower().lstrip(".") + logger.info( + "Attachment read started", + extra={ + "attachment_id": attachment.pk, + "conversation_id": attachment.conversation_id, + "original_name": attachment.original_name, + "file_type": file_type, + "storage_path": attachment.storage_path, + "resolved_path": str(file_path), + }, + ) + + if not file_path.exists(): + logger.warning( + "Attachment read missing file", + extra={"attachment_id": attachment.pk, "resolved_path": str(file_path)}, + ) + return _failed(attachment, file_type, "附件文件不存在。") + if file_type not in SUPPORTED_EXTENSIONS: + logger.warning( + "Attachment read unsupported type", + extra={"attachment_id": attachment.pk, "file_type": file_type}, + ) + return _failed(attachment, file_type, f"暂不支持解析 .{file_type or 'unknown'} 文件。", "unsupported") + + try: + if file_type == "pdf": + sections = _read_pdf(file_path) + elif file_type == "docx": + sections = _read_docx(file_path) + elif file_type == "xlsx": + sections = _read_xlsx(file_path) + elif file_type == "pptx": + sections = _read_pptx(file_path) + elif file_type == "csv": + sections = _read_csv(file_path) + elif file_type in ARCHIVE_EXTENSIONS: + sections = _read_archive(file_path) + else: + sections = _read_text(file_path) + except Exception as exc: + logger.exception( + "Attachment read failed", + extra={"attachment_id": attachment.pk, "file_type": file_type, "error": str(exc)}, + ) + return _failed(attachment, file_type, str(exc)) + + preview = _build_preview(sections) + logger.info( + "Attachment read finished", + extra={ + "attachment_id": attachment.pk, + "section_count": len(sections), + "preview_length": len(preview), + }, + ) + return AttachmentReadResult( + status="success", + filename=attachment.original_name, + file_type=file_type, + file_size=attachment.file_size, + preview_text=preview[:MAX_PREVIEW_CHARS], + sections=sections, + ) + + +def _attachment_absolute_path(attachment: FileAttachment) -> Path: + path = Path(attachment.storage_path) + if path.is_absolute(): + return path + return Path(settings.MEDIA_ROOT) / path + + +def _failed( + attachment: FileAttachment, + file_type: str, + message: str, + status: str = "failed", +) -> AttachmentReadResult: + return AttachmentReadResult( + status=status, + filename=attachment.original_name, + file_type=file_type, + file_size=attachment.file_size, + error_message=message, + ) + + +def _read_text(path: Path) -> list[dict[str, object]]: + text = path.read_text(encoding="utf-8", errors="replace") + return [{"type": "text", "name": path.name, "text": text[:MAX_PREVIEW_CHARS]}] + + +def _read_csv(path: Path) -> list[dict[str, object]]: + with path.open("r", encoding="utf-8-sig", errors="replace", newline="") as handle: + rows = [[str(cell) for cell in row] for row in csv.reader(handle)] + return [ + { + "type": "table", + "name": path.name, + "row_count": len(rows), + "rows": rows[:MAX_ROWS_PER_SHEET], + } + ] + + +def _read_pdf(path: Path) -> list[dict[str, object]]: + from pypdf import PdfReader + + reader = PdfReader(str(path)) + pages = [] + for index, page in enumerate(reader.pages, start=1): + text = page.extract_text() or "" + pages.append({"type": "page", "name": f"第 {index} 页", "text": text}) + return pages + + +def _read_docx(path: Path) -> list[dict[str, object]]: + from docx import Document + + document = Document(str(path)) + paragraphs = [item.text.strip() for item in document.paragraphs if item.text.strip()] + sections: list[dict[str, object]] = [ + {"type": "text", "name": "正文", "text": "\n".join(paragraphs)} + ] + for index, table in enumerate(document.tables, start=1): + rows = [[cell.text.strip() for cell in row.cells] for row in table.rows] + sections.append( + { + "type": "table", + "name": f"表格 {index}", + "row_count": len(rows), + "rows": rows[:MAX_ROWS_PER_SHEET], + } + ) + return sections + + +def _read_xlsx(path: Path) -> list[dict[str, object]]: + from openpyxl import load_workbook + + workbook = load_workbook(str(path), read_only=True, data_only=True) + sections = [] + for sheet in workbook.worksheets: + rows = [] + for row in sheet.iter_rows(max_row=MAX_ROWS_PER_SHEET, values_only=True): + rows.append(["" if cell is None else str(cell) for cell in row]) + sections.append( + { + "type": "sheet", + "name": sheet.title, + "row_count": sheet.max_row, + "column_count": sheet.max_column, + "rows": rows, + } + ) + workbook.close() + return sections + + +def _read_pptx(path: Path) -> list[dict[str, object]]: + from pptx import Presentation + + presentation = Presentation(str(path)) + sections = [] + for index, slide in enumerate(presentation.slides, start=1): + texts = [] + for shape in slide.shapes: + if hasattr(shape, "text") and shape.text.strip(): + texts.append(shape.text.strip()) + sections.append({"type": "slide", "name": f"幻灯片 {index}", "text": "\n".join(texts)}) + return sections + + +def _read_archive(path: Path) -> list[dict[str, object]]: + sections: list[dict[str, object]] = [] + with TemporaryDirectory(prefix="attachment-reader-") as temp_dir: + extracted = extract_archive(path, Path(temp_dir)) + if not extracted: + return [{"type": "archive", "name": path.name, "text": "压缩包未解出任何可读取文件。"}] + for item in extracted: + file_type = item.suffix.lower().lstrip(".") + if file_type not in SUPPORTED_EXTENSIONS or file_type in ARCHIVE_EXTENSIONS: + sections.append( + { + "type": "file", + "name": item.name, + "text": f"暂不支持预览压缩包内的 .{file_type or 'unknown'} 文件。", + } + ) + continue + for section in _read_supported_file(item, file_type): + section = dict(section) + section["name"] = f"{item.name} / {section.get('name', item.name)}" + sections.append(section) + return sections + + +def _read_supported_file(path: Path, file_type: str) -> list[dict[str, object]]: + if file_type == "pdf": + return _read_pdf(path) + if file_type == "docx": + return _read_docx(path) + if file_type == "xlsx": + return _read_xlsx(path) + if file_type == "pptx": + return _read_pptx(path) + if file_type == "csv": + return _read_csv(path) + return _read_text(path) + + +def _build_preview(sections: list[dict[str, object]]) -> str: + parts: list[str] = [] + for section in sections: + if "text" in section and section["text"]: + parts.append(str(section["text"])) + rows = section.get("rows") + if rows: + parts.extend(" | ".join(str(cell) for cell in row) for row in rows[:5]) + return "\n".join(part for part in parts if part).strip() diff --git a/review_agent/file_summary/services/export_excel.py b/review_agent/file_summary/services/export_excel.py new file mode 100644 index 0000000..b203cb3 --- /dev/null +++ b/review_agent/file_summary/services/export_excel.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +from django.conf import settings +from openpyxl import Workbook + +from review_agent.models import ExportedSummaryFile, FileSummaryBatch + + +logger = logging.getLogger("review_agent.file_summary.export_excel") + + +def _exports_dir(batch: FileSummaryBatch) -> Path: + root = Path(batch.work_dir) if batch.work_dir else Path(settings.MEDIA_ROOT) / "file_summary" / batch.batch_no + export_dir = root / "exports" + export_dir.mkdir(parents=True, exist_ok=True) + return export_dir + + +def generate_excel_export(batch: FileSummaryBatch) -> ExportedSummaryFile: + logger.info("Excel export generation started", extra={"batch_id": batch.pk}) + workbook = Workbook() + summary = workbook.active + summary.title = "汇总信息" + summary.append(["批次号", batch.batch_no]) + summary.append(["产品名称", batch.product_name or "-"]) + summary.append(["文件总数", batch.total_files]) + summary.append(["统计成功", batch.success_files]) + summary.append(["统计失败", batch.failed_files]) + summary.append(["不支持", batch.unsupported_files]) + summary.append(["不确定", batch.uncertain_files]) + summary.append(["总页数", batch.total_pages]) + + detail = workbook.create_sheet("文件明细") + detail.append(["序号", "目录层级", "文件名", "类型", "页数", "路径", "状态", "重试次数", "异常说明"]) + for item in batch.items.order_by("file_index"): + detail.append( + [ + item.file_index, + item.directory_level, + item.file_name, + item.file_type, + item.page_count, + item.relative_path, + item.statistics_status, + item.retry_count, + item.error_message, + ] + ) + + path = _exports_dir(batch) / f"{batch.batch_no}-summary.xlsx" + workbook.save(path) + exported = ExportedSummaryFile.objects.create( + batch=batch, + workflow_type="file_summary", + workflow_batch_id=batch.pk, + export_category="summary", + export_type=ExportedSummaryFile.ExportType.EXCEL, + file_name=path.name, + storage_path=str(path), + ) + logger.info( + "Excel export generation finished", + extra={"batch_id": batch.pk, "export_id": exported.pk, "path": str(path)}, + ) + return exported diff --git a/review_agent/file_summary/services/inventory.py b/review_agent/file_summary/services/inventory.py new file mode 100644 index 0000000..e7282db --- /dev/null +++ b/review_agent/file_summary/services/inventory.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from pathlib import Path + +from review_agent.models import FileSummaryBatch, FileSummaryItem + + +SUPPORTED_EXTENSIONS = {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx"} + + +def _directory_level(relative_path: Path) -> str: + if len(relative_path.parts) <= 1: + return "" + return "/".join(relative_path.parts[:-1]) + + +def scan_files_to_items(*, batch: FileSummaryBatch, roots: list[Path]) -> list[FileSummaryItem]: + files: list[tuple[Path, Path]] = [] + for root in roots: + root = Path(root) + if root.is_file(): + files.append((root.parent, root)) + continue + for path in sorted(item for item in root.rglob("*") if item.is_file()): + if path.name.startswith(".") or path.stat().st_size == 0: + continue + files.append((root, path)) + + created: list[FileSummaryItem] = [] + for index, (root, path) in enumerate(files, start=1): + relative = path.relative_to(root).as_posix() + file_type = path.suffix.lower().lstrip(".") + item = FileSummaryItem.objects.create( + batch=batch, + file_index=index, + directory_level=_directory_level(Path(relative)), + file_name=path.name, + file_type=file_type, + relative_path=relative, + storage_path=str(path), + statistics_status=FileSummaryItem.StatisticsStatus.SKIPPED, + ) + created.append(item) + + batch.total_files = len(created) + batch.supported_files = sum(1 for item in created if item.file_type in SUPPORTED_EXTENSIONS) + batch.unsupported_files = len(created) - batch.supported_files + batch.save(update_fields=["total_files", "supported_files", "unsupported_files"]) + return created diff --git a/review_agent/file_summary/services/page_count.py b/review_agent/file_summary/services/page_count.py new file mode 100644 index 0000000..4f1e63a --- /dev/null +++ b/review_agent/file_summary/services/page_count.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path +from xml.etree import ElementTree +from zipfile import ZipFile, is_zipfile + + +SUPPORTED_EXTENSIONS = {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx"} +logger = logging.getLogger("review_agent.file_summary.page_count") + + +@dataclass(frozen=True) +class PageCountResult: + status: str + page_count: int | None = None + error_message: str = "" + + +def count_document_pages(path: str | Path) -> PageCountResult: + file_path = Path(path) + ext = file_path.suffix.lower().lstrip(".") + if ext not in SUPPORTED_EXTENSIONS: + return PageCountResult(status="unsupported") + + try: + if ext == "pdf": + from pypdf import PdfReader + + return PageCountResult(status="success", page_count=len(PdfReader(str(file_path)).pages)) + if ext == "docx": + pages = _count_docx_pages_from_extended_properties(file_path) + if pages: + return PageCountResult(status="success", page_count=pages) + pages = _count_word_pages_with_com(file_path) if _can_try_com_fallback(file_path, ext) else None + if pages: + return PageCountResult(status="success", page_count=pages) + return PageCountResult(status="uncertain") + if ext == "xlsx": + pages = _count_xlsx_sheets(file_path) or ( + _count_excel_sheets_with_com(file_path) if _can_try_com_fallback(file_path, ext) else None + ) + if pages: + return PageCountResult(status="success", page_count=pages) + return PageCountResult(status="uncertain") + if ext == "xls": + pages = _count_xls_sheets(file_path) or ( + _count_excel_sheets_with_com(file_path) if _can_try_com_fallback(file_path, ext) else None + ) + if pages: + return PageCountResult(status="success", page_count=pages) + return PageCountResult(status="uncertain") + if ext == "pptx": + pages = _count_pptx_slides(file_path) or ( + _count_powerpoint_slides_with_com(file_path) if _can_try_com_fallback(file_path, ext) else None + ) + if pages: + return PageCountResult(status="success", page_count=pages) + return PageCountResult(status="uncertain") + if ext == "doc": + pages = _count_word_pages_with_com(file_path) if _can_try_com_fallback(file_path, ext) else None + if pages: + return PageCountResult(status="success", page_count=pages) + return _ole_uncertain_or_failed(file_path) + if ext == "ppt": + pages = _count_powerpoint_slides_with_com(file_path) if _can_try_com_fallback(file_path, ext) else None + if pages: + return PageCountResult(status="success", page_count=pages) + return _ole_uncertain_or_failed(file_path) + except Exception as exc: + return PageCountResult(status="failed", error_message=str(exc)) + + return PageCountResult(status="uncertain") + + +def _count_docx_pages_from_extended_properties(path: Path) -> int | None: + try: + with ZipFile(path) as archive: + app_entries = [ + item for item in archive.infolist() if item.filename == "docProps/app.xml" + ] + if not app_entries: + return None + content = archive.read(app_entries[-1]).decode("utf-8", errors="replace") + except Exception as exc: + logger.warning("DOCX extended properties read failed", extra={"path": str(path), "error": str(exc)}) + return None + + try: + root = ElementTree.fromstring(content) + except ElementTree.ParseError as exc: + logger.warning("DOCX extended properties parse failed", extra={"path": str(path), "error": str(exc)}) + return None + + pages_node = root.find("{http://schemas.openxmlformats.org/officeDocument/2006/extended-properties}Pages") + if pages_node is None or not pages_node.text: + return None + return _positive_int(pages_node.text) + + +def _count_xlsx_sheets(path: Path) -> int | None: + try: + from openpyxl import load_workbook + + workbook = load_workbook(str(path), read_only=True, data_only=True) + try: + return _positive_int(len(workbook.sheetnames)) + finally: + workbook.close() + except Exception as exc: + logger.warning("XLSX sheet count failed", extra={"path": str(path), "error": str(exc)}) + return None + + +def _count_xls_sheets(path: Path) -> int | None: + try: + import xlrd + + workbook = xlrd.open_workbook(str(path), on_demand=True) + try: + return _positive_int(workbook.nsheets) + finally: + workbook.release_resources() + except Exception as exc: + logger.warning("XLS sheet count failed", extra={"path": str(path), "error": str(exc)}) + return None + + +def _count_pptx_slides(path: Path) -> int | None: + try: + from pptx import Presentation + + return _positive_int(len(Presentation(str(path)).slides)) + except Exception as exc: + logger.warning("PPTX slide count failed", extra={"path": str(path), "error": str(exc)}) + return None + + +def _ole_uncertain_or_failed(path: Path) -> PageCountResult: + try: + import olefile + + if olefile.isOleFile(str(path)): + return PageCountResult(status="uncertain") + return PageCountResult(status="failed", error_message="不是有效的 OLE 文件。") + except Exception as exc: + logger.warning("OLE validation failed", extra={"path": str(path), "error": str(exc)}) + return PageCountResult(status="uncertain") + + +def _can_try_com_fallback(path: Path, ext: str) -> bool: + if ext in {"docx", "xlsx", "pptx"}: + return is_zipfile(path) + if ext in {"doc", "xls", "ppt"}: + try: + import olefile + + return olefile.isOleFile(str(path)) + except Exception as exc: + logger.warning("OLE signature check failed", extra={"path": str(path), "error": str(exc)}) + return False + return False + + +def _count_word_pages_with_com(path: Path) -> int | None: + try: + import pythoncom + import win32com.client + except Exception as exc: + logger.info("Word COM page count unavailable", extra={"path": str(path), "error": str(exc)}) + return None + + word = None + document = None + pythoncom.CoInitialize() + try: + word = win32com.client.DispatchEx("Word.Application") + word.Visible = False + word.DisplayAlerts = 0 + document = word.Documents.Open( + str(path.resolve()), + ReadOnly=True, + AddToRecentFiles=False, + ConfirmConversions=False, + ) + document.Repaginate() + return _positive_int(document.ComputeStatistics(2)) + except Exception as exc: + logger.warning("Word COM page count failed", extra={"path": str(path), "error": str(exc)}) + return None + finally: + try: + if document is not None: + document.Close(False) + except Exception as exc: + logger.debug("Word document close failed", extra={"path": str(path), "error": str(exc)}) + try: + if word is not None: + word.Quit() + except Exception as exc: + logger.debug("Word application quit failed", extra={"path": str(path), "error": str(exc)}) + pythoncom.CoUninitialize() + + +def _count_powerpoint_slides_with_com(path: Path) -> int | None: + try: + import pythoncom + import win32com.client + except Exception as exc: + logger.info("PowerPoint COM slide count unavailable", extra={"path": str(path), "error": str(exc)}) + return None + + powerpoint = None + presentation = None + pythoncom.CoInitialize() + try: + powerpoint = win32com.client.DispatchEx("PowerPoint.Application") + presentation = powerpoint.Presentations.Open( + str(path.resolve()), + ReadOnly=True, + Untitled=False, + WithWindow=False, + ) + return _positive_int(presentation.Slides.Count) + except Exception as exc: + logger.warning("PowerPoint COM slide count failed", extra={"path": str(path), "error": str(exc)}) + return None + finally: + try: + if presentation is not None: + presentation.Close() + except Exception as exc: + logger.debug("PowerPoint presentation close failed", extra={"path": str(path), "error": str(exc)}) + try: + if powerpoint is not None: + powerpoint.Quit() + except Exception as exc: + logger.debug("PowerPoint application quit failed", extra={"path": str(path), "error": str(exc)}) + pythoncom.CoUninitialize() + + +def _count_excel_sheets_with_com(path: Path) -> int | None: + try: + import pythoncom + import win32com.client + except Exception as exc: + logger.info("Excel COM sheet count unavailable", extra={"path": str(path), "error": str(exc)}) + return None + + excel = None + workbook = None + pythoncom.CoInitialize() + try: + excel = win32com.client.DispatchEx("Excel.Application") + excel.Visible = False + excel.DisplayAlerts = False + workbook = excel.Workbooks.Open(str(path.resolve()), ReadOnly=True) + return _positive_int(workbook.Worksheets.Count) + except Exception as exc: + logger.warning("Excel COM sheet count failed", extra={"path": str(path), "error": str(exc)}) + return None + finally: + try: + if workbook is not None: + workbook.Close(False) + except Exception as exc: + logger.debug("Excel workbook close failed", extra={"path": str(path), "error": str(exc)}) + try: + if excel is not None: + excel.Quit() + except Exception as exc: + logger.debug("Excel application quit failed", extra={"path": str(path), "error": str(exc)}) + pythoncom.CoUninitialize() + + +def _positive_int(value) -> int | None: + try: + number = int(value) + except (TypeError, ValueError): + return None + return number if number > 0 else None diff --git a/review_agent/file_summary/services/product_detect.py b/review_agent/file_summary/services/product_detect.py new file mode 100644 index 0000000..ff48dba --- /dev/null +++ b/review_agent/file_summary/services/product_detect.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from pathlib import Path + +from review_agent.models import FileSummaryBatch + + +def detect_product_name(batch: FileSummaryBatch) -> str: + product_name = "" + for item in batch.items.order_by("file_index"): + parts = Path(item.relative_path).parts + if len(parts) > 1: + product_name = parts[0] + break + name = Path(item.file_name).stem + for keyword in ("产品", "试剂盒", "说明书"): + if keyword in name: + product_name = name + break + if product_name: + break + + if not product_name: + return "" + + batch.product_name = product_name + batch.save(update_fields=["product_name"]) + if batch.conversation.title.startswith("新对话"): + batch.conversation.title = f"{product_name}-文件汇总" + batch.conversation.save(update_fields=["title", "updated_at"]) + return product_name diff --git a/review_agent/file_summary/services/report.py b/review_agent/file_summary/services/report.py new file mode 100644 index 0000000..5543daa --- /dev/null +++ b/review_agent/file_summary/services/report.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +from django.conf import settings + +from review_agent.models import ExportedSummaryFile, FileSummaryBatch + + +logger = logging.getLogger("review_agent.file_summary.report") + + +def _exports_dir(batch: FileSummaryBatch) -> Path: + root = Path(batch.work_dir) if batch.work_dir else Path(settings.MEDIA_ROOT) / "file_summary" / batch.batch_no + export_dir = root / "exports" + export_dir.mkdir(parents=True, exist_ok=True) + return export_dir + + +def build_summary_table(batch: FileSummaryBatch) -> str: + lines = [ + "| 序号 | 目录层级 | 文件名 | 类型 | 页数 | 状态 | 异常说明 |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + for item in batch.items.order_by("file_index"): + lines.append( + "| {index} | {directory} | {name} | {file_type} | {pages} | {status} | {error} |".format( + index=item.file_index, + directory=item.directory_level or "-", + name=item.file_name, + file_type=item.file_type, + pages=item.page_count if item.page_count is not None else "-", + status=item.statistics_status, + error=item.error_message or "-", + ) + ) + return "\n".join(lines) + + +def build_markdown_report(batch: FileSummaryBatch) -> str: + return "\n\n".join( + [ + f"# 文件目录与页数汇总报告\n\n批次号:{batch.batch_no}", + ( + "## 汇总信息\n\n" + f"- 产品名称:{batch.product_name or '-'}\n" + f"- 文件总数:{batch.total_files}\n" + f"- 统计成功:{batch.success_files}\n" + f"- 统计失败:{batch.failed_files}\n" + f"- 不支持:{batch.unsupported_files}\n" + f"- 不确定:{batch.uncertain_files}\n" + f"- 总页数:{batch.total_pages}" + ), + "## 文件明细\n\n" + build_summary_table(batch), + "## 处理说明\n\n单文件失败不会阻断批次,失败与不确定文件已在明细中标注。", + ] + ) + + +def generate_markdown_report(batch: FileSummaryBatch) -> tuple[ExportedSummaryFile, str]: + logger.info("Markdown report generation started", extra={"batch_id": batch.pk}) + content = build_markdown_report(batch) + path = _exports_dir(batch) / f"{batch.batch_no}-summary.md" + path.write_text(content, encoding="utf-8") + exported = ExportedSummaryFile.objects.create( + batch=batch, + workflow_type="file_summary", + workflow_batch_id=batch.pk, + export_category="summary", + export_type=ExportedSummaryFile.ExportType.MARKDOWN, + file_name=path.name, + storage_path=str(path), + ) + logger.info( + "Markdown report generation finished", + extra={"batch_id": batch.pk, "export_id": exported.pk, "path": str(path)}, + ) + return exported, build_summary_table(batch) diff --git a/review_agent/file_summary/skills/__init__.py b/review_agent/file_summary/skills/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/review_agent/file_summary/skills/__init__.py @@ -0,0 +1 @@ + diff --git a/review_agent/file_summary/skills/archive_extract.py b/review_agent/file_summary/skills/archive_extract.py new file mode 100644 index 0000000..bf2c71b --- /dev/null +++ b/review_agent/file_summary/skills/archive_extract.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import logging +from pathlib import Path +import re + +from review_agent.models import FileSummaryBatchAttachment + +from ..paths import resolve_storage_path +from ..services.archive import ARCHIVE_EXTENSIONS, extract_archive +from .base import BaseSkill, SkillResult, WorkflowContext + + +logger = logging.getLogger("review_agent.file_summary.skills.archive_extract") + + +def _safe_archive_dir_name(binding: FileSummaryBatchAttachment) -> str: + stem = Path(binding.attachment.original_name).stem or "archive" + safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "_", stem).strip("._") or "archive" + return f"{binding.attachment_id}_{safe_stem}" + + +class ArchiveExtractSkill(BaseSkill): + name = "archive_extract" + + def run(self, context: WorkflowContext) -> SkillResult: + extracted_count = 0 + if not context.batch.work_dir: + message = "批次工作目录为空,无法解压压缩包。" + logger.error( + "Archive extract failed without work dir", + extra={"batch_id": context.batch.pk, "batch_no": context.batch.batch_no}, + ) + return SkillResult(success=False, message=message, data={"extracted_count": 0}) + target_root = Path(context.batch.work_dir) + + archive_count = 0 + for binding in FileSummaryBatchAttachment.objects.filter(batch=context.batch): + path = resolve_storage_path(binding.attachment.storage_path) + if path.suffix.lower().lstrip(".") not in ARCHIVE_EXTENSIONS: + continue + archive_count += 1 + target_dir = target_root / "extracted" / _safe_archive_dir_name(binding) + logger.info( + "Archive extract started", + extra={ + "batch_id": context.batch.pk, + "attachment_id": binding.attachment_id, + "path": str(path), + "target_dir": str(target_dir), + }, + ) + extracted_count += len(extract_archive(path, target_dir)) + if archive_count and extracted_count == 0: + message = "压缩包未解出任何可扫描文件,请检查压缩包内容或格式。" + logger.warning( + "Archive extract produced no files", + extra={"batch_id": context.batch.pk, "archive_count": archive_count}, + ) + return SkillResult(success=False, message=message, data={"extracted_count": 0}) + logger.info( + "Archive extract finished", + extra={ + "batch_id": context.batch.pk, + "archive_count": archive_count, + "extracted_count": extracted_count, + }, + ) + return SkillResult(success=True, data={"extracted_count": extracted_count}) diff --git a/review_agent/file_summary/skills/attachment_reader.py b/review_agent/file_summary/skills/attachment_reader.py new file mode 100644 index 0000000..1ebdf5c --- /dev/null +++ b/review_agent/file_summary/skills/attachment_reader.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import logging +from collections.abc import Iterable + +from review_agent.models import FileAttachment + +from ..services.attachment_reader import read_attachment_details +from .base import BaseSkill, SkillResult, WorkflowContext + + +logger = logging.getLogger("review_agent.file_summary.skills.attachment_reader") + + +class AttachmentReaderSkill(BaseSkill): + name = "attachment_reader" + + def run(self, context: WorkflowContext) -> SkillResult: + attachments = FileAttachment.objects.filter( + conversation=context.batch.conversation, + is_active=True, + ).exclude(upload_status=FileAttachment.UploadStatus.DELETED) + return self.run_for_attachments(attachments) + + def run_for_attachments(self, attachments: Iterable[FileAttachment]) -> SkillResult: + attachment_list = list(attachments) + logger.info( + "Attachment reader skill started", + extra={ + "attachment_count": len(attachment_list), + "attachment_ids": [attachment.pk for attachment in attachment_list], + }, + ) + results = [read_attachment_details(attachment).to_dict() for attachment in attachment_list] + if not results: + logger.warning("Attachment reader skill found no attachments") + return SkillResult(success=False, message="当前对话没有可读取的附件。") + + has_success = any(item["status"] == "success" for item in results) + logger.info( + "Attachment reader skill finished", + extra={ + "success": has_success, + "success_count": sum(1 for item in results if item["status"] == "success"), + "failed_count": sum(1 for item in results if item["status"] != "success"), + }, + ) + return SkillResult( + success=has_success, + data={"attachments": results}, + message="附件解析完成。" if has_success else "附件解析失败。", + ) diff --git a/review_agent/file_summary/skills/base.py b/review_agent/file_summary/skills/base.py new file mode 100644 index 0000000..b8e6313 --- /dev/null +++ b/review_agent/file_summary/skills/base.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from review_agent.models import FileSummaryBatch + + +@dataclass(frozen=True) +class WorkflowContext: + batch: FileSummaryBatch + + +@dataclass +class SkillResult: + success: bool + data: dict = field(default_factory=dict) + message: str = "" + + +class BaseSkill: + name = "" + + def run(self, context: WorkflowContext) -> SkillResult: + raise NotImplementedError diff --git a/review_agent/file_summary/skills/document_page_count.py b/review_agent/file_summary/skills/document_page_count.py new file mode 100644 index 0000000..5b4e4e4 --- /dev/null +++ b/review_agent/file_summary/skills/document_page_count.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import logging + +from review_agent.models import FileSummaryItem + +from ..services.page_count import SUPPORTED_EXTENSIONS, count_document_pages +from .base import BaseSkill, SkillResult, WorkflowContext + + +logger = logging.getLogger("review_agent.file_summary.skills.document_page_count") + + +class DocumentPageCountSkill(BaseSkill): + name = "document_page_count" + + def run(self, context: WorkflowContext) -> SkillResult: + success_files = failed_files = unsupported_files = uncertain_files = total_pages = 0 + logger.info("Document page count started", extra={"batch_id": context.batch.pk}) + for item in context.batch.items.order_by("file_index"): + if item.file_type not in SUPPORTED_EXTENSIONS: + item.statistics_status = FileSummaryItem.StatisticsStatus.UNSUPPORTED + unsupported_files += 1 + item.save(update_fields=["statistics_status", "updated_at"]) + logger.info( + "Document page count unsupported", + extra={ + "batch_id": context.batch.pk, + "item_id": item.pk, + "file_type": item.file_type, + "file_name": item.file_name, + }, + ) + continue + + result = None + for attempt in range(1, 4): + logger.info( + "Document page count attempt", + extra={ + "batch_id": context.batch.pk, + "item_id": item.pk, + "attempt": attempt, + "storage_path": item.storage_path, + }, + ) + result = count_document_pages(item.storage_path) + item.retry_count = attempt - 1 + if result.status != "failed": + break + item.statistics_status = result.status + item.page_count = result.page_count + item.error_message = result.error_message + item.save( + update_fields=[ + "statistics_status", + "page_count", + "retry_count", + "error_message", + "updated_at", + ] + ) + + if result.status == FileSummaryItem.StatisticsStatus.SUCCESS: + success_files += 1 + total_pages += result.page_count or 0 + elif result.status == FileSummaryItem.StatisticsStatus.UNCERTAIN: + uncertain_files += 1 + elif result.status == FileSummaryItem.StatisticsStatus.UNSUPPORTED: + unsupported_files += 1 + else: + failed_files += 1 + logger.warning( + "Document page count failed", + extra={ + "batch_id": context.batch.pk, + "item_id": item.pk, + "file_name": item.file_name, + "error": result.error_message, + }, + ) + + context.batch.success_files = success_files + context.batch.failed_files = failed_files + context.batch.unsupported_files = unsupported_files + context.batch.uncertain_files = uncertain_files + context.batch.total_pages = total_pages + context.batch.save( + update_fields=[ + "success_files", + "failed_files", + "unsupported_files", + "uncertain_files", + "total_pages", + ] + ) + logger.info( + "Document page count finished", + extra={ + "batch_id": context.batch.pk, + "success_files": success_files, + "failed_files": failed_files, + "unsupported_files": unsupported_files, + "uncertain_files": uncertain_files, + "total_pages": total_pages, + }, + ) + return SkillResult(success=True) diff --git a/review_agent/file_summary/skills/file_inventory.py b/review_agent/file_summary/skills/file_inventory.py new file mode 100644 index 0000000..0de852c --- /dev/null +++ b/review_agent/file_summary/skills/file_inventory.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import logging +from pathlib import Path +import re + +from review_agent.models import FileSummaryBatchAttachment + +from ..paths import resolve_storage_path +from ..services.archive import ARCHIVE_EXTENSIONS +from ..services.inventory import scan_files_to_items +from .base import BaseSkill, SkillResult, WorkflowContext + + +logger = logging.getLogger("review_agent.file_summary.skills.file_inventory") + + +def _safe_archive_dir_name(binding: FileSummaryBatchAttachment) -> str: + stem = Path(binding.attachment.original_name).stem or "archive" + safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "_", stem).strip("._") or "archive" + return f"{binding.attachment_id}_{safe_stem}" + + +class FileInventorySkill(BaseSkill): + name = "file_inventory" + + def run(self, context: WorkflowContext) -> SkillResult: + roots: list[Path] = [] + missing_extract_roots: list[str] = [] + for binding in FileSummaryBatchAttachment.objects.filter(batch=context.batch): + original_path = resolve_storage_path(binding.attachment.storage_path) + is_archive = original_path.suffix.lower().lstrip(".") in ARCHIVE_EXTENSIONS + if not is_archive: + roots.append(original_path) + continue + + extracted_root = ( + Path(context.batch.work_dir) + / "extracted" + / _safe_archive_dir_name(binding) + ) + if extracted_root.exists(): + roots.append(extracted_root) + else: + missing_extract_roots.append(str(extracted_root)) + if missing_extract_roots: + message = "压缩包解压目录不存在,无法扫描解压后的文件。" + logger.warning( + "File inventory missing extracted roots", + extra={ + "batch_id": context.batch.pk, + "missing_extract_roots": missing_extract_roots, + }, + ) + return SkillResult(success=False, message=message) + logger.info( + "File inventory started", + extra={ + "batch_id": context.batch.pk, + "root_count": len(roots), + "roots": [str(root) for root in roots], + }, + ) + items = scan_files_to_items(batch=context.batch, roots=roots) + logger.info( + "File inventory finished", + extra={"batch_id": context.batch.pk, "total_files": len(items)}, + ) + return SkillResult(success=True, data={"total_files": len(items)}) diff --git a/review_agent/file_summary/skills/product_detect.py b/review_agent/file_summary/skills/product_detect.py new file mode 100644 index 0000000..188b84c --- /dev/null +++ b/review_agent/file_summary/skills/product_detect.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import logging + +from ..services.product_detect import detect_product_name +from .base import BaseSkill, SkillResult, WorkflowContext + + +logger = logging.getLogger("review_agent.file_summary.skills.product_detect") + + +class ProductDetectSkill(BaseSkill): + name = "product_detect" + + def run(self, context: WorkflowContext) -> SkillResult: + logger.info("Product detect started", extra={"batch_id": context.batch.pk}) + product_name = detect_product_name(context.batch) + logger.info( + "Product detect finished", + extra={"batch_id": context.batch.pk, "product_name": product_name}, + ) + return SkillResult(success=True, data={"product_name": product_name}) diff --git a/review_agent/file_summary/skills/registry.py b/review_agent/file_summary/skills/registry.py new file mode 100644 index 0000000..b49a614 --- /dev/null +++ b/review_agent/file_summary/skills/registry.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import logging + +from .base import BaseSkill, SkillResult, WorkflowContext + + +logger = logging.getLogger("review_agent.file_summary.skills") + + +class SkillRegistry: + def __init__(self): + self._skills: dict[str, BaseSkill] = {} + + def register(self, skill: BaseSkill) -> None: + if not skill.name: + raise ValueError("Skill 必须声明 name。") + self._skills[skill.name] = skill + logger.info("Skill registered: %s", skill.name, extra={"skill_name": skill.name}) + + def get(self, name: str) -> BaseSkill: + try: + return self._skills[name] + except KeyError as exc: + raise KeyError(f"Skill 未注册:{name}") from exc + + def execute(self, name: str, context: WorkflowContext) -> SkillResult: + logger.info("Skill started: %s", name, extra={"skill_name": name, "batch_id": context.batch.pk}) + try: + result = self.get(name).run(context) + except Exception: + logger.exception("Skill crashed: %s", name, extra={"skill_name": name, "batch_id": context.batch.pk}) + raise + logger.info( + "Skill finished: %s", + name, + extra={ + "skill_name": name, + "batch_id": context.batch.pk, + "success": result.success, + "result_message": result.message, + }, + ) + return result diff --git a/review_agent/file_summary/skills/summary_report.py b/review_agent/file_summary/skills/summary_report.py new file mode 100644 index 0000000..c70cdf9 --- /dev/null +++ b/review_agent/file_summary/skills/summary_report.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import logging + +from django.urls import reverse + +from review_agent.models import Message + +from ..services.export_excel import generate_excel_export +from ..services.report import generate_markdown_report +from .base import BaseSkill, SkillResult, WorkflowContext + + +logger = logging.getLogger("review_agent.file_summary.skills.summary_report") + + +class SummaryReportSkill(BaseSkill): + name = "summary_report" + + def run(self, context: WorkflowContext) -> SkillResult: + logger.info("Summary report started", extra={"batch_id": context.batch.pk}) + markdown_export, summary_table = generate_markdown_report(context.batch) + excel_export = generate_excel_export(context.batch) + markdown_url = reverse("file_summary_export_download", args=[markdown_export.pk]) + excel_url = reverse("file_summary_export_download", args=[excel_export.pk]) + content = ( + "文件目录与页数汇总已完成。\n\n" + f"{summary_table}\n\n" + f"[下载 Markdown 报告]({markdown_url}) | [下载 Excel 明细]({excel_url})" + ) + Message.objects.create( + conversation=context.batch.conversation, + role=Message.Role.ASSISTANT, + content=content, + ) + logger.info( + "Summary report finished", + extra={ + "batch_id": context.batch.pk, + "markdown_export_id": markdown_export.pk, + "excel_export_id": excel_export.pk, + }, + ) + return SkillResult( + success=True, + data={"markdown_export_id": markdown_export.pk, "excel_export_id": excel_export.pk}, + ) diff --git a/review_agent/file_summary/storage.py b/review_agent/file_summary/storage.py new file mode 100644 index 0000000..413c768 --- /dev/null +++ b/review_agent/file_summary/storage.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from uuid import uuid4 + +from django.conf import settings +from django.db import transaction +from django.utils.text import get_valid_filename + +from review_agent.models import Conversation, FileAttachment + +from .constants import ATTACHMENT_ROOT + + +logger = logging.getLogger("review_agent.file_summary.storage") + + +def _safe_original_name(name: str) -> str: + clean = get_valid_filename(Path(name).name) + return clean or f"upload-{uuid4().hex}" + + +def _relative_attachment_path(conversation: Conversation, filename: str, version_no: int) -> Path: + suffix = Path(filename).suffix + stem = Path(filename).stem + stored_name = f"{stem}_v{version_no}_{uuid4().hex[:8]}{suffix}" + return ( + ATTACHMENT_ROOT + / str(conversation.user_id) + / str(conversation.pk) + / "attachments" + / stored_name + ) + + +def _ensure_inside_media_root(path: Path) -> None: + media_root = Path(settings.MEDIA_ROOT).resolve() + resolved = path.resolve() + if media_root != resolved and media_root not in resolved.parents: + raise ValueError("上传路径必须位于 MEDIA_ROOT 内。") + + +@transaction.atomic +def save_uploaded_attachment(*, conversation: Conversation, user, uploaded_file) -> FileAttachment: + """Stores an uploaded file and creates a versioned attachment record.""" + + original_name = _safe_original_name(uploaded_file.name) + logger.info( + "Attachment upload save started", + extra={ + "conversation_id": conversation.pk, + "user_id": user.pk, + "original_name": original_name, + "file_size": uploaded_file.size, + "content_type": getattr(uploaded_file, "content_type", "") or "", + }, + ) + latest = ( + FileAttachment.objects.filter(conversation=conversation, original_name=original_name) + .order_by("-version_no") + .first() + ) + version_no = (latest.version_no if latest else 0) + 1 + relative_path = _relative_attachment_path(conversation, original_name, version_no) + absolute_path = Path(settings.MEDIA_ROOT) / relative_path + _ensure_inside_media_root(absolute_path) + absolute_path.parent.mkdir(parents=True, exist_ok=True) + + with absolute_path.open("wb") as target: + for chunk in uploaded_file.chunks(): + target.write(chunk) + + FileAttachment.objects.filter( + conversation=conversation, + original_name=original_name, + is_active=True, + ).update(is_active=False) + + attachment = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name=original_name, + version_no=version_no, + is_active=True, + storage_path=relative_path.as_posix(), + file_size=uploaded_file.size, + content_type=getattr(uploaded_file, "content_type", "") or "", + ) + logger.info( + "Attachment upload save finished", + extra={ + "conversation_id": conversation.pk, + "attachment_id": attachment.pk, + "version_no": attachment.version_no, + "storage_path": attachment.storage_path, + }, + ) + return attachment + + +def serialize_attachment(attachment: FileAttachment) -> dict[str, object]: + return { + "id": attachment.pk, + "original_name": attachment.original_name, + "version_no": attachment.version_no, + "is_active": attachment.is_active, + "file_size": attachment.file_size, + "content_type": attachment.content_type, + "upload_status": attachment.upload_status, + "created_at": attachment.created_at.isoformat(), + } diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py new file mode 100644 index 0000000..f475d95 --- /dev/null +++ b/review_agent/file_summary/views.py @@ -0,0 +1,365 @@ +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Count, Q +import json +import logging +from pathlib import Path + +from django.http import FileResponse, Http404, JsonResponse +from django.views.decorators.http import require_http_methods + +from review_agent.models import ( + ApplicationFormFillBatch, + Conversation, + ExportedSummaryFile, + FileAttachment, + Message, + RegulatoryInfoPackageBatch, + RegulatoryReviewBatch, +) +from review_agent.models import FileSummaryBatch, WorkflowEvent +from review_agent.notifications.presenter import serialize_notification_records +from .events import serialize_event +from .paths import resolve_storage_path + +from .storage import save_uploaded_attachment, serialize_attachment + + +logger = logging.getLogger("review_agent.file_summary.views") + + +def _conversation_for_user(user, conversation_id: int) -> Conversation: + conversation = Conversation.objects.filter(pk=conversation_id, user=user).first() + if not conversation: + raise Http404("对话不存在。") + return conversation + + +@require_http_methods(["POST", "GET"]) +@login_required +def attachments(request, conversation_id: int): + conversation = _conversation_for_user(request.user, conversation_id) + + if request.method == "POST": + files = request.FILES.getlist("files") + if not files: + return JsonResponse({"error": "请选择至少一个文件。"}, status=400) + logger.info( + "Attachment upload request received", + extra={ + "conversation_id": conversation.pk, + "user_id": request.user.pk, + "file_count": len(files), + "filenames": [uploaded_file.name for uploaded_file in files], + }, + ) + saved = [ + save_uploaded_attachment( + conversation=conversation, + user=request.user, + uploaded_file=uploaded_file, + ) + for uploaded_file in files + ] + logger.info( + "Attachment upload request finished", + extra={ + "conversation_id": conversation.pk, + "attachment_ids": [attachment.pk for attachment in saved], + }, + ) + return JsonResponse({"attachments": [serialize_attachment(item) for item in saved]}) + + queryset = FileAttachment.objects.filter(conversation=conversation).order_by( + "original_name", + "-version_no", + ) + logger.info( + "Attachment list requested", + extra={"conversation_id": conversation.pk, "attachment_count": queryset.count()}, + ) + return JsonResponse({"attachments": [serialize_attachment(item) for item in queryset]}) + + +@require_http_methods(["DELETE", "PATCH"]) +@login_required +def attachment_detail(request, conversation_id: int, attachment_id: int): + conversation = _conversation_for_user(request.user, conversation_id) + attachment = FileAttachment.objects.filter( + pk=attachment_id, + conversation=conversation, + user=request.user, + ).first() + if not attachment: + raise Http404("附件不存在。") + + if request.method == "PATCH": + try: + payload = json.loads(request.body.decode("utf-8") or "{}") + except json.JSONDecodeError: + return JsonResponse({"error": "JSON 格式错误。"}, status=400) + + update_fields = [] + original_name = (payload.get("original_name") or "").strip() + if original_name: + attachment.original_name = Path(original_name).name + update_fields.append("original_name") + if "is_active" in payload: + attachment.is_active = bool(payload["is_active"]) + update_fields.append("is_active") + if update_fields: + attachment.save(update_fields=update_fields) + logger.info( + "Attachment updated", + extra={ + "conversation_id": conversation.pk, + "attachment_id": attachment.pk, + "update_fields": update_fields, + }, + ) + return JsonResponse({"ok": True, "attachment": serialize_attachment(attachment)}) + + attachment.upload_status = FileAttachment.UploadStatus.DELETED + attachment.is_active = False + attachment.save(update_fields=["upload_status", "is_active"]) + logger.info( + "Attachment deleted", + extra={"conversation_id": conversation.pk, "attachment_id": attachment.pk}, + ) + return JsonResponse({"ok": True, "attachment": serialize_attachment(attachment)}) + + +@require_http_methods(["GET"]) +@login_required +def conversation_list(request): + conversations = ( + Conversation.objects.filter(user=request.user) + .annotate( + attachment_count=Count( + "file_attachments", + filter=~Q(file_attachments__upload_status=FileAttachment.UploadStatus.DELETED), + ) + ) + .order_by("-updated_at", "-id") + ) + return JsonResponse( + { + "conversations": [ + { + "id": conversation.pk, + "title": conversation.title or "新对话", + "updated_at": conversation.updated_at.isoformat(), + "attachment_count": conversation.attachment_count, + } + for conversation in conversations + ] + } + ) + + +@require_http_methods(["DELETE"]) +@login_required +def conversation_detail(request, conversation_id: int): + conversation = _conversation_for_user(request.user, conversation_id) + with transaction.atomic(): + ApplicationFormFillBatch.objects.filter(conversation=conversation).delete() + RegulatoryReviewBatch.objects.filter(conversation=conversation).delete() + conversation.delete() + return JsonResponse({"ok": True, "conversation_id": conversation_id}) + + +@require_http_methods(["GET"]) +@login_required +def attachment_download(request, conversation_id: int, attachment_id: int): + conversation = _conversation_for_user(request.user, conversation_id) + attachment = FileAttachment.objects.filter( + pk=attachment_id, + conversation=conversation, + user=request.user, + ).exclude(upload_status=FileAttachment.UploadStatus.DELETED).first() + if not attachment: + raise Http404("附件不存在。") + + path = resolve_storage_path(attachment.storage_path) + if not path.exists(): + logger.warning( + "Attachment download missing file", + extra={"attachment_id": attachment.pk, "storage_path": attachment.storage_path}, + ) + return JsonResponse({"error": "文件不存在。"}, status=404) + logger.info( + "Attachment download started", + extra={"conversation_id": conversation.pk, "attachment_id": attachment.pk}, + ) + return FileResponse( + path.open("rb"), + as_attachment=True, + filename=attachment.original_name, + content_type=attachment.content_type or "application/octet-stream", + ) + + +def _serialize_message(message: Message) -> dict[str, object]: + return { + "id": message.pk, + "role": message.role, + "content": message.content, + "created_at": message.created_at.isoformat(), + } + + +@require_http_methods(["GET"]) +@login_required +def conversation_messages(request, conversation_id: int): + conversation = _conversation_for_user(request.user, conversation_id) + after = request.GET.get("after") or "0" + try: + after_id = int(after) + except ValueError: + after_id = 0 + + messages = list(conversation.messages.filter(pk__gt=after_id).order_by("id")) + latest_message_id = ( + conversation.messages.order_by("-id").values_list("id", flat=True).first() or 0 + ) + logger.info( + "Conversation incremental messages requested", + extra={ + "conversation_id": conversation.pk, + "after_id": after_id, + "message_count": len(messages), + "latest_message_id": latest_message_id, + }, + ) + return JsonResponse( + { + "conversation_id": conversation.pk, + "latest_message_id": latest_message_id, + "messages": [_serialize_message(message) for message in messages], + } + ) + + +@require_http_methods(["GET"]) +@login_required +def batch_status(request, batch_id: int): + batch = FileSummaryBatch.objects.filter(pk=batch_id, user=request.user).first() + if not batch: + raise Http404("批次不存在。") + notifications = serialize_notification_records("file_summary", batch.pk) + return JsonResponse( + { + "batch": { + "id": batch.pk, + "workflow_type": "file_summary", + "batch_no": batch.batch_no, + "status": batch.status, + "product_name": batch.product_name, + "total_files": batch.total_files, + "success_files": batch.success_files, + "failed_files": batch.failed_files, + "total_pages": batch.total_pages, + "error_message": batch.error_message, + }, + "nodes": [ + { + "node_code": node.node_code, + "node_name": node.node_name, + "status": node.status, + "progress": node.progress, + "message": node.message, + } + for node in batch.node_runs.order_by("id") + ], + "notifications": notifications, + "latest_notification": notifications[0] if notifications else None, + } + ) + + +@require_http_methods(["GET"]) +@login_required +def batch_events(request, batch_id: int): + batch = FileSummaryBatch.objects.filter(pk=batch_id, user=request.user).first() + if not batch: + raise Http404("批次不存在。") + after = request.GET.get("after") or "0" + try: + after_id = int(after) + except ValueError: + after_id = 0 + events = WorkflowEvent.objects.filter(batch=batch, pk__gt=after_id).order_by("id") + return JsonResponse({"events": [serialize_event(event) for event in events]}) + + +@require_http_methods(["GET"]) +@login_required +def export_download(request, export_id: int): + exported = _export_for_user(request.user, export_id) + if not exported: + raise Http404("导出文件不存在。") + path = Path(exported.storage_path) + if not path.exists(): + logger.warning( + "Export download missing file", + extra={"export_id": exported.pk, "storage_path": exported.storage_path}, + ) + return JsonResponse({"error": "文件不存在。"}, status=404) + suffix = Path(exported.file_name).suffix.lower() + content_types = { + ExportedSummaryFile.ExportType.MARKDOWN: "text/markdown; charset=utf-8", + ExportedSummaryFile.ExportType.EXCEL: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ExportedSummaryFile.ExportType.JSON: "application/json; charset=utf-8", + ExportedSummaryFile.ExportType.WORD: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ExportedSummaryFile.ExportType.PDF: "application/pdf", + ExportedSummaryFile.ExportType.ZIP: "application/zip", + } + content_type = content_types.get(exported.export_type, "application/octet-stream") + if exported.export_type == ExportedSummaryFile.ExportType.WORD and suffix == ".doc": + content_type = "application/msword" + elif exported.export_type == ExportedSummaryFile.ExportType.WORD and suffix == ".docx": + content_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + logger.info( + "Export download started", + extra={ + "export_id": exported.pk, + "batch_id": exported.batch_id, + "file_name": exported.file_name, + "content_type": content_type, + }, + ) + return FileResponse( + path.open("rb"), + as_attachment=True, + filename=exported.file_name, + content_type=content_type, + ) + + +def _export_for_user(user, export_id: int) -> ExportedSummaryFile | None: + exported = ExportedSummaryFile.objects.filter(pk=export_id).first() + if not exported: + return None + if exported.workflow_type == "application_form_fill": + if not exported.workflow_batch_id: + return None + allowed = ApplicationFormFillBatch.objects.filter( + pk=exported.workflow_batch_id, + conversation__user=user, + is_deleted=False, + ).exists() + return exported if allowed else None + if exported.workflow_type == "regulatory_info_package": + if not exported.workflow_batch_id: + return None + allowed = RegulatoryInfoPackageBatch.objects.filter( + pk=exported.workflow_batch_id, + conversation__user=user, + is_deleted=False, + ).exists() + return exported if allowed else None + if exported.batch_id is None: + return None + if exported.batch.user_id != user.pk: + return None + return exported diff --git a/review_agent/file_summary/workflow.py b/review_agent/file_summary/workflow.py new file mode 100644 index 0000000..5409b2c --- /dev/null +++ b/review_agent/file_summary/workflow.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from threading import Thread +from uuid import uuid4 + +from django.conf import settings +from django.db import transaction +from django.utils import timezone + +from review_agent.models import ( + Conversation, + FileAttachment, + FileSummaryBatch, + FileSummaryBatchAttachment, + Message, + WorkflowNodeRun, +) +from review_agent.notifications.dispatcher import dispatch_workflow_notification +from review_agent.notifications.workflow_adapters import build_file_summary_context + +from .events import record_event +from .services.archive import ARCHIVE_EXTENSIONS +from .skills.archive_extract import ArchiveExtractSkill +from .skills.base import WorkflowContext +from .skills.document_page_count import DocumentPageCountSkill +from .skills.file_inventory import FileInventorySkill +from .skills.product_detect import ProductDetectSkill +from .skills.registry import SkillRegistry +from .skills.summary_report import SummaryReportSkill + + +NODE_DEFINITIONS = [ + ("upload", "附件固化", ""), + ("extract", "压缩包解压", "archive_extract"), + ("inventory", "文件扫描", "file_inventory"), + ("page_count", "页数统计", "document_page_count"), + ("product_detect", "产品识别", "product_detect"), + ("report", "报告输出", "summary_report"), + ("complete", "完成", ""), +] + + +logger = logging.getLogger("review_agent.file_summary.workflow") + + +def default_skill_registry() -> SkillRegistry: + registry = SkillRegistry() + registry.register(ArchiveExtractSkill()) + registry.register(FileInventorySkill()) + registry.register(DocumentPageCountSkill()) + registry.register(ProductDetectSkill()) + registry.register(SummaryReportSkill()) + return registry + + +def build_batch_no() -> str: + return f"FS-{timezone.localtime().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6]}" + + +def build_batch_work_dir(batch_no: str) -> Path: + return Path(settings.MEDIA_ROOT) / "file_summary" / "work" / batch_no + + +@transaction.atomic +def create_file_summary_batch( + *, + conversation: Conversation, + user, + trigger_message: Message | None = None, +) -> FileSummaryBatch: + active_attachments = list( + FileAttachment.objects.select_for_update() + .filter(conversation=conversation, is_active=True) + .exclude(upload_status=FileAttachment.UploadStatus.DELETED) + .order_by("original_name", "-created_at") + ) + if not active_attachments: + raise ValueError("当前对话没有可用附件。") + logger.info( + "File summary batch creation started", + extra={ + "conversation_id": conversation.pk, + "user_id": user.pk, + "attachment_ids": [attachment.pk for attachment in active_attachments], + }, + ) + + batch_no = build_batch_no() + work_dir = build_batch_work_dir(batch_no) + work_dir.mkdir(parents=True, exist_ok=True) + + batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + trigger_message=trigger_message, + batch_no=batch_no, + work_dir=str(work_dir), + ) + + for attachment in active_attachments: + source_role = ( + FileSummaryBatchAttachment.SourceRole.ARCHIVE + if Path(attachment.original_name).suffix.lower().lstrip(".") in ARCHIVE_EXTENSIONS + else FileSummaryBatchAttachment.SourceRole.MULTI_FILE + ) + FileSummaryBatchAttachment.objects.create( + batch=batch, + attachment=attachment, + source_role=source_role, + ) + attachment.upload_status = FileAttachment.UploadStatus.BOUND + attachment.save(update_fields=["upload_status"]) + + for code, name, _skill_name in NODE_DEFINITIONS: + WorkflowNodeRun.objects.create( + batch=batch, + workflow_type="file_summary", + workflow_batch_id=batch.pk, + node_group="file_summary", + node_code=code, + node_name=name, + ) + + record_event(batch, "workflow_created", {"batch_id": batch.pk, "batch_no": batch.batch_no}) + logger.info( + "File summary batch created", + extra={"batch_id": batch.pk, "batch_no": batch.batch_no}, + ) + return batch + + +class WorkflowExecutor: + def __init__(self, batch: FileSummaryBatch, registry: SkillRegistry | None = None): + self.batch = batch + self.registry = registry or default_skill_registry() + + def run(self) -> None: + logger.info("Workflow run started", extra={"batch_id": self.batch.pk}) + self.batch.status = FileSummaryBatch.Status.RUNNING + self.batch.started_at = timezone.now() + self.batch.save(update_fields=["status", "started_at"]) + record_event(self.batch, "workflow_started", {"batch_id": self.batch.pk}) + + try: + for node in self.batch.node_runs.order_by("id"): + self._run_node(node) + except Exception as exc: + logger.exception( + "Workflow run failed", + extra={"batch_id": self.batch.pk, "error": str(exc)}, + ) + self.batch.status = FileSummaryBatch.Status.FAILED + self.batch.error_message = str(exc) + self.batch.finished_at = timezone.now() + self.batch.save(update_fields=["status", "error_message", "finished_at"]) + record_event(self.batch, "workflow_failed", {"message": str(exc)}) + self._dispatch_completion_notification() + return + + self.batch.status = FileSummaryBatch.Status.SUCCESS + self.batch.finished_at = timezone.now() + self.batch.save(update_fields=["status", "finished_at"]) + record_event(self.batch, "workflow_completed", {"batch_id": self.batch.pk}) + self._dispatch_completion_notification() + logger.info("Workflow run completed", extra={"batch_id": self.batch.pk}) + + def _dispatch_completion_notification(self) -> None: + try: + dispatch_workflow_notification(build_file_summary_context(self.batch)) + except Exception as exc: + logger.warning( + "File summary notification failed without blocking workflow", + extra={"batch_id": self.batch.pk, "error": str(exc)}, + ) + + def _run_node(self, node: WorkflowNodeRun) -> None: + logger.info( + "Workflow node started", + extra={ + "batch_id": self.batch.pk, + "node_code": node.node_code, + "node_name": node.node_name, + }, + ) + now = timezone.now() + node.status = WorkflowNodeRun.Status.RUNNING + node.progress = 10 + node.started_at = now + node.message = f"{node.node_name}处理中" + node.save(update_fields=["status", "progress", "started_at", "message"]) + record_event( + self.batch, + "node_progress", + {"node_code": node.node_code, "status": node.status, "progress": node.progress, "message": node.message}, + ) + + skill_name = next( + (skill for code, _name, skill in NODE_DEFINITIONS if code == node.node_code), + "", + ) + if skill_name: + try: + result = self.registry.execute(skill_name, WorkflowContext(batch=self.batch)) + if not result.success: + logger.warning( + "Workflow node skill failed", + extra={ + "batch_id": self.batch.pk, + "node_code": node.node_code, + "skill_name": skill_name, + "result_message": result.message, + }, + ) + raise RuntimeError(result.message or f"{node.node_name}执行失败") + except Exception as exc: + node.status = WorkflowNodeRun.Status.FAILED + node.finished_at = timezone.now() + node.message = str(exc) + node.save(update_fields=["status", "finished_at", "message"]) + record_event( + self.batch, + "node_progress", + { + "node_code": node.node_code, + "status": node.status, + "progress": node.progress, + "message": node.message, + }, + ) + raise + + node.status = WorkflowNodeRun.Status.SUCCESS + node.progress = 100 + node.finished_at = timezone.now() + node.message = f"{node.node_name}完成" + node.save(update_fields=["status", "progress", "finished_at", "message"]) + record_event( + self.batch, + "node_progress", + {"node_code": node.node_code, "status": node.status, "progress": node.progress, "message": node.message}, + ) + logger.info( + "Workflow node finished", + extra={"batch_id": self.batch.pk, "node_code": node.node_code}, + ) + + +def start_file_summary_workflow(batch: FileSummaryBatch, *, async_run: bool = True) -> None: + executor = WorkflowExecutor(batch) + if not async_run: + logger.info("Workflow starting synchronously", extra={"batch_id": batch.pk}) + executor.run() + return + logger.info("Workflow starting asynchronously", extra={"batch_id": batch.pk}) + Thread(target=executor.run, daemon=True).start() diff --git a/review_agent/file_summary/workflow_trigger.py b/review_agent/file_summary/workflow_trigger.py new file mode 100644 index 0000000..cb53efe --- /dev/null +++ b/review_agent/file_summary/workflow_trigger.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from review_agent.models import Conversation, FileAttachment + + +TRIGGER_KEYWORDS = ("自动汇总", "文件目录", "页数", "目录与页数", "文件清单") +ATTACHMENT_READER_KEYWORDS = ( + "阅读附件", + "读取附件", + "解析附件", + "分析附件", + "查看附件", + "附件详情", + "文件详情", + "文件内容", + "附件内容", + "简历文件", + "提供的文件", + "提供的简历", + "上传的文件", + "上传文件", + "这个文件", + "该文件", + "总结附件", + "总结文件", + "分析这个文件", + "阅读这个文件", +) +ATTACHMENT_REFERENCE_KEYWORDS = ("附件", "文件", "简历", "上传") +ATTACHMENT_READ_INTENT_KEYWORDS = ( + "阅读", + "读取", + "读", + "解析", + "分析", + "查看", + "提取", + "整理", + "总结", + "介绍", + "项目经历", + "工作经历", + "经历", + "信息", + "内容", +) + + +@dataclass(frozen=True) +class TriggerResult: + should_start: bool + workflow_type: str = "" + reason: str = "" + + +def evaluate_file_summary_trigger(conversation: Conversation, content: str) -> TriggerResult: + text = (content or "").strip() + if not any(keyword in text for keyword in TRIGGER_KEYWORDS): + return TriggerResult(should_start=False, reason="not_matched") + + has_attachment = FileAttachment.objects.filter( + conversation=conversation, + is_active=True, + ).exclude(upload_status=FileAttachment.UploadStatus.DELETED).exists() + if not has_attachment: + return TriggerResult(should_start=False, reason="missing_attachment") + + return TriggerResult(should_start=True, workflow_type="file_summary") + + +def evaluate_attachment_reader_trigger(conversation: Conversation, content: str) -> TriggerResult: + text = (content or "").strip() + matched = any(keyword in text for keyword in ATTACHMENT_READER_KEYWORDS) or ( + any(keyword in text for keyword in ATTACHMENT_REFERENCE_KEYWORDS) + and any(keyword in text for keyword in ATTACHMENT_READ_INTENT_KEYWORDS) + ) + if not matched: + return TriggerResult(should_start=False, reason="not_matched") + + has_attachment = FileAttachment.objects.filter( + conversation=conversation, + is_active=True, + ).exclude(upload_status=FileAttachment.UploadStatus.DELETED).exists() + if not has_attachment: + return TriggerResult(should_start=False, reason="missing_attachment") + + return TriggerResult(should_start=True, workflow_type="attachment_reader") diff --git a/review_agent/knowledge_base.py b/review_agent/knowledge_base.py new file mode 100644 index 0000000..de2714b --- /dev/null +++ b/review_agent/knowledge_base.py @@ -0,0 +1,415 @@ +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from django.conf import settings +from django.core.files.uploadedfile import UploadedFile + +from review_agent.models import KnowledgeBaseDocument +from review_agent.regulatory_review.services.rag_citation import RagIndexUnavailable, retrieve_citations +from review_agent.regulatory_review.services.rag_embedding import get_embedding_provider +from review_agent.regulatory_review.services.rag_index import chunk_text, extract_text_from_path, is_excluded_source_path +from review_agent.regulatory_review.services.rule_loader import DEFAULT_RULE_PATH, compute_file_sha256, load_rule_file + + +SUPPORTED_SOURCE_SUFFIXES = {".doc", ".docx", ".pdf", ".txt", ".md", ".pptx", ".xlsx"} + + +@dataclass(frozen=True) +class ChromaCollectionState: + exists: bool + count: int = 0 + error_message: str = "" + sample_metadatas: list[dict[str, Any]] | None = None + source_chunk_counts: dict[str, int] | None = None + + +def build_knowledge_base_context() -> dict[str, Any]: + rule_info = _rule_info() + source_dir = Path(settings.BASE_DIR) / str(rule_info.get("source_material_dir") or "docs/0.原始材料") + sources = list_source_documents(source_dir) + collection = get_chroma_collection_state() + return { + "name": "NMPA IVD 注册资料法规库", + "description": "用于体外诊断试剂注册资料法规核查的结构化规则和 RAG 依据检索。", + "provider": settings.REGULATORY_RAG_PROVIDER, + "collection_name": settings.REGULATORY_RAG_COLLECTION, + "chroma_path": settings.REGULATORY_RAG_CHROMA_PATH, + "rule": rule_info, + "source_dir": str(source_dir), + "sources": sources, + "source_count": len(sources), + "supported_source_count": sum(1 for item in sources if item["supported"]), + "collection": { + "exists": collection.exists, + "count": collection.count, + "error_message": collection.error_message, + "sample_metadatas": collection.sample_metadatas or [], + }, + "status": _status_label(collection), + "build_commands": [ + "python manage.py regulatory_rag_build --provider deterministic", + "python manage.py regulatory_rag_build --provider siliconflow", + ], + "managed_documents": [], + } + + +def build_knowledge_base_context_for_user(user) -> dict[str, Any]: + context = build_knowledge_base_context() + documents = list_documents_for_user(user) + context["managed_documents"] = documents + context["managed_document_count"] = len(documents) + context["active_managed_document_count"] = sum(1 for item in documents if item["is_active"]) + return context + + +def list_source_documents(source_dir: Path) -> list[dict[str, Any]]: + if not source_dir.exists(): + return [] + collection = get_chroma_collection_state() + source_chunk_counts = collection.source_chunk_counts or {} + documents: list[dict[str, Any]] = [] + for path in sorted(source_dir.rglob("*")): + if not path.is_file(): + continue + suffix = path.suffix.lower() + relative_path = str(path.relative_to(source_dir)) + if is_excluded_source_path(relative_path): + continue + indexed_chunk_count = source_chunk_counts.get(relative_path, 0) + documents.append( + { + "name": path.name, + "relative_path": relative_path, + "suffix": suffix.lstrip(".") or "unknown", + "size": path.stat().st_size, + "supported": suffix in SUPPORTED_SOURCE_SUFFIXES, + "indexed": indexed_chunk_count > 0, + "indexed_chunk_count": indexed_chunk_count, + "indexed_label": f"已入库 {indexed_chunk_count} 片" if indexed_chunk_count else "未入库", + } + ) + return documents + + +def search_knowledge_base(query: str, *, n_results: int = 3) -> dict[str, Any]: + normalized = (query or "").strip() + if not normalized: + return {"query": normalized, "results": [], "error_message": "请输入检索问题。"} + try: + results = retrieve_citations( + normalized, + embedding_provider=get_embedding_provider(), + n_results=n_results, + ) + except RagIndexUnavailable as exc: + return {"query": normalized, "results": [], "error_message": str(exc)} + except Exception as exc: + return {"query": normalized, "results": [], "error_message": f"检索失败:{exc}"} + return {"query": normalized, "results": filter_active_knowledge_results(results), "error_message": ""} + + +def list_documents_for_user(user) -> list[dict[str, Any]]: + return [ + serialize_document(document) + for document in KnowledgeBaseDocument.objects.filter(user=user).exclude(status=KnowledgeBaseDocument.Status.DELETED) + ] + + +def create_document_from_upload( + *, + user, + uploaded_file: UploadedFile, + display_name: str = "", + description: str = "", + is_active: bool = True, +) -> KnowledgeBaseDocument: + root = Path(settings.MEDIA_ROOT) / "knowledge_base" / "users" / str(user.pk) + root.mkdir(parents=True, exist_ok=True) + target = _unique_target_path(root, uploaded_file.name) + with target.open("wb") as handle: + for chunk in uploaded_file.chunks(): + handle.write(chunk) + status = KnowledgeBaseDocument.Status.ACTIVE if is_active else KnowledgeBaseDocument.Status.DISABLED + document = KnowledgeBaseDocument.objects.create( + user=user, + display_name=(display_name or uploaded_file.name).strip(), + original_name=uploaded_file.name, + storage_path=str(target), + file_size=target.stat().st_size, + content_type=getattr(uploaded_file, "content_type", "") or "", + description=description.strip(), + status=status, + is_active=is_active, + ) + if is_active: + index_managed_document(document) + return document + + +def update_document(document: KnowledgeBaseDocument, payload: dict[str, Any]) -> KnowledgeBaseDocument: + update_fields = [] + active_changed = False + if "display_name" in payload: + document.display_name = str(payload.get("display_name") or "").strip() or document.original_name + update_fields.append("display_name") + if "description" in payload: + document.description = str(payload.get("description") or "").strip() + update_fields.append("description") + if "is_active" in payload: + next_is_active = bool(payload.get("is_active")) + active_changed = document.is_active != next_is_active + document.is_active = next_is_active + document.status = KnowledgeBaseDocument.Status.ACTIVE if next_is_active else KnowledgeBaseDocument.Status.DISABLED + update_fields.extend(["is_active", "status"]) + if not next_is_active: + remove_managed_document_from_index(document) + document.indexed_chunk_count = 0 + document.metadata = {**(document.metadata or {}), "index_status": "disabled", "index_error": ""} + update_fields.extend(["indexed_chunk_count", "metadata"]) + if update_fields: + update_fields.append("updated_at") + document.save(update_fields=update_fields) + if active_changed and document.is_active: + index_managed_document(document) + return document + + +def delete_document(document: KnowledgeBaseDocument) -> KnowledgeBaseDocument: + remove_managed_document_from_index(document) + document.status = KnowledgeBaseDocument.Status.DELETED + document.is_active = False + document.indexed_chunk_count = 0 + document.metadata = {**(document.metadata or {}), "index_status": "deleted", "index_error": ""} + document.save(update_fields=["status", "is_active", "indexed_chunk_count", "metadata", "updated_at"]) + return document + + +def serialize_document(document: KnowledgeBaseDocument) -> dict[str, Any]: + indexed_label = f"已入库 {document.indexed_chunk_count} 片" if document.indexed_chunk_count else "未入库" + return { + "id": document.pk, + "display_name": document.display_name, + "original_name": document.original_name, + "description": document.description, + "file_size": document.file_size, + "content_type": document.content_type, + "status": document.status, + "is_active": document.is_active, + "indexed_chunk_count": document.indexed_chunk_count, + "indexed_label": indexed_label, + "created_at": document.created_at.isoformat() if document.created_at else "", + "updated_at": document.updated_at.isoformat() if document.updated_at else "", + } + + +def index_managed_document(document: KnowledgeBaseDocument) -> int: + if document.status != KnowledgeBaseDocument.Status.ACTIVE or not document.is_active: + remove_managed_document_from_index(document) + document.indexed_chunk_count = 0 + document.metadata = {**(document.metadata or {}), "index_status": "disabled", "index_error": ""} + document.save(update_fields=["indexed_chunk_count", "metadata", "updated_at"]) + return 0 + path = Path(document.storage_path) + if not path.is_absolute(): + path = Path(settings.MEDIA_ROOT) / document.storage_path + try: + text = extract_text_from_path(path) + source = f"用户知识库/{document.user_id}/{document.pk}/{document.original_name}" + chunks = chunk_text(text, source=source) + if not chunks: + document.indexed_chunk_count = 0 + document.metadata = {**(document.metadata or {}), "index_status": "empty", "index_error": ""} + document.save(update_fields=["indexed_chunk_count", "metadata", "updated_at"]) + return 0 + collection = _load_chroma_collection() + texts = [chunk.text for chunk in chunks] + embeddings = get_embedding_provider()(texts) + ids = [ + hashlib.sha256(f"managed:{document.pk}:{chunk.metadata['chunk_index']}".encode("utf-8")).hexdigest() + for chunk in chunks + ] + metadatas = [ + { + **chunk.metadata, + "source_type": "managed_document", + "document_id": document.pk, + "user_id": document.user_id, + "original_name": document.original_name, + } + for chunk in chunks + ] + collection.upsert(ids=ids, documents=texts, metadatas=metadatas, embeddings=embeddings) + document.indexed_chunk_count = len(chunks) + document.metadata = {**(document.metadata or {}), "index_status": "indexed", "index_error": ""} + document.save(update_fields=["indexed_chunk_count", "metadata", "updated_at"]) + return len(chunks) + except Exception as exc: + document.indexed_chunk_count = 0 + document.metadata = {**(document.metadata or {}), "index_status": "failed", "index_error": str(exc)} + document.save(update_fields=["indexed_chunk_count", "metadata", "updated_at"]) + return 0 + + +def remove_managed_document_from_index(document: KnowledgeBaseDocument) -> None: + try: + collection = _load_chroma_collection() + collection.delete(where={"document_id": document.pk}) + except Exception as exc: + document.metadata = {**(document.metadata or {}), "index_delete_error": str(exc)} + + +def filter_active_knowledge_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]: + managed_ids = { + int((item.get("metadata") or {}).get("document_id")) + for item in results + if (item.get("metadata") or {}).get("source_type") == "managed_document" + and (item.get("metadata") or {}).get("document_id") is not None + } + if not managed_ids: + return results + active_ids = set( + KnowledgeBaseDocument.objects.filter( + pk__in=managed_ids, + status=KnowledgeBaseDocument.Status.ACTIVE, + is_active=True, + ).values_list("pk", flat=True) + ) + filtered = [] + for item in results: + metadata = item.get("metadata") or {} + if metadata.get("source_type") != "managed_document": + filtered.append(item) + continue + try: + document_id = int(metadata.get("document_id")) + except (TypeError, ValueError): + continue + if document_id in active_ids: + filtered.append(item) + return filtered + + +def _load_chroma_collection(): + try: + import chromadb + except ImportError as exc: + raise RuntimeError("chromadb 未安装。") from exc + persist_path = Path(settings.REGULATORY_RAG_CHROMA_PATH) + persist_path.mkdir(parents=True, exist_ok=True) + return chromadb.PersistentClient(path=str(persist_path)).get_or_create_collection( + settings.REGULATORY_RAG_COLLECTION + ) + + +def get_chroma_collection_state() -> ChromaCollectionState: + persist_path = Path(settings.REGULATORY_RAG_CHROMA_PATH) + if not persist_path.exists(): + return ChromaCollectionState(exists=False, error_message="法规 RAG 索引目录不存在。") + try: + import chromadb + except ImportError: + return ChromaCollectionState(exists=False, error_message="chromadb 未安装。") + try: + collection = chromadb.PersistentClient(path=str(persist_path)).get_collection(settings.REGULATORY_RAG_COLLECTION) + count = collection.count() + metadatas = _load_collection_metadatas(collection, count) + return ChromaCollectionState( + exists=True, + count=count, + sample_metadatas=metadatas[:10], + source_chunk_counts=_count_chunks_by_source(metadatas), + ) + except Exception as exc: + return ChromaCollectionState(exists=False, error_message=f"法规 RAG collection 不可用:{exc}") + + +def _load_collection_metadatas(collection, count: int) -> list[dict[str, Any]]: + metadatas: list[dict[str, Any]] = [] + if count <= 0: + return metadatas + page_size = 500 + for offset in range(0, count, page_size): + payload = collection.get( + include=["metadatas"], + limit=min(page_size, count - offset), + offset=offset, + ) + metadatas.extend(payload.get("metadatas") or []) + return metadatas + + +def _count_chunks_by_source(metadatas: list[dict[str, Any]]) -> dict[str, int]: + counts: dict[str, int] = {} + for metadata in metadatas: + source = str((metadata or {}).get("source") or "") + if source: + counts[source] = counts.get(source, 0) + 1 + return counts + + +def _rule_info() -> dict[str, Any]: + try: + payload = load_rule_file() + requirements = payload.get("requirements") or [] + severity_counts: dict[str, int] = {} + chapter_codes = set() + for requirement in requirements: + severity = str(requirement.get("severity") or "unknown") + severity_counts[severity] = severity_counts.get(severity, 0) + 1 + attachment4_code = str(requirement.get("attachment4_code") or "") + if attachment4_code: + chapter_codes.add(attachment4_code.split(".")[0]) + return { + "status": "ok", + "code": payload.get("code", ""), + "name": payload.get("name", ""), + "path": str(DEFAULT_RULE_PATH), + "hash": compute_file_sha256(DEFAULT_RULE_PATH), + "rag_collection": payload.get("rag_collection", ""), + "source_material_dir": payload.get("source_material_dir", "docs/0.原始材料"), + "requirement_count": len(requirements), + "chapter_count": len(chapter_codes), + "severity_counts": severity_counts, + } + except Exception as exc: + return { + "status": "failed", + "code": "", + "name": "", + "path": str(DEFAULT_RULE_PATH), + "hash": "", + "rag_collection": "", + "source_material_dir": "docs/0.原始材料", + "requirement_count": 0, + "chapter_count": 0, + "severity_counts": {}, + "error_message": str(exc), + } + + +def _status_label(collection: ChromaCollectionState) -> dict[str, str]: + if not collection.exists: + return {"code": "missing", "label": "未构建", "message": collection.error_message} + if collection.count < 20: + return {"code": "thin", "label": "索引过少", "message": "RAG 能力已打通,但当前索引内容较少,建议补齐材料后重建。"} + return {"code": "ready", "label": "可用", "message": "RAG 索引已构建,可用于法规依据辅助检索。"} + + +def _unique_target_path(root: Path, original_name: str) -> Path: + safe_name = Path(original_name).name or "document" + target = root / safe_name + if not target.exists(): + return target + stem = target.stem + suffix = target.suffix + index = 2 + while True: + candidate = root / f"{stem}-{index}{suffix}" + if not candidate.exists(): + return candidate + index += 1 diff --git a/review_agent/llm.py b/review_agent/llm.py new file mode 100644 index 0000000..ee2fd5d --- /dev/null +++ b/review_agent/llm.py @@ -0,0 +1,190 @@ +import json +import logging +from urllib import error, request + +from django.conf import settings + + +class LLMConfigurationError(RuntimeError): + """Raised when the project has not been configured with a usable LLM provider.""" + + +class LLMRequestError(RuntimeError): + """Raised when the remote LLM provider call fails.""" + + +logger = logging.getLogger(__name__) + + +def generate_reply(conversation, user_message: str, knowledge_context: str = "") -> str: + """Calls the SiliconFlow OpenAI-compatible chat endpoint and returns assistant text.""" + + if not settings.LLM_API_KEY: + raise LLMConfigurationError("缺少 LLM_API_KEY 配置。") + if not settings.LLM_MODEL: + raise LLMConfigurationError("缺少 LLM_MODEL 配置。") + + payload = { + "model": settings.LLM_MODEL, + "messages": build_messages(conversation, user_message, knowledge_context=knowledge_context), + "temperature": 0.3, + } + body = json.dumps(payload).encode("utf-8") + endpoint = f"{settings.LLM_BASE_URL.rstrip('/')}/chat/completions" + + http_request = request.Request( + endpoint, + data=body, + headers={ + "Authorization": f"Bearer {settings.LLM_API_KEY}", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with request.urlopen(http_request, timeout=60) as response: + data = json.loads(response.read().decode("utf-8")) + except error.HTTPError as exc: + details = exc.read().decode("utf-8", errors="ignore") + raise LLMRequestError(f"模型接口调用失败:HTTP {exc.code} {details}") from exc + except error.URLError as exc: + raise LLMRequestError(f"模型接口调用失败:{exc.reason}") from exc + + try: + return data["choices"][0]["message"]["content"].strip() + except (KeyError, IndexError, TypeError) as exc: + raise LLMRequestError("模型接口返回格式不符合预期。") from exc + + +def generate_completion(messages: list[dict[str, str]], *, temperature: float = 0.0, timeout: float = 60) -> str: + """Calls the configured chat endpoint with explicit messages and returns assistant text.""" + + if not settings.LLM_API_KEY: + raise LLMConfigurationError("缺少 LLM_API_KEY 配置。") + if not settings.LLM_MODEL: + raise LLMConfigurationError("缺少 LLM_MODEL 配置。") + + payload = { + "model": settings.LLM_MODEL, + "messages": messages, + "temperature": temperature, + } + body = json.dumps(payload).encode("utf-8") + endpoint = f"{settings.LLM_BASE_URL.rstrip('/')}/chat/completions" + + http_request = request.Request( + endpoint, + data=body, + headers={ + "Authorization": f"Bearer {settings.LLM_API_KEY}", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with request.urlopen(http_request, timeout=timeout) as response: + data = json.loads(response.read().decode("utf-8")) + except error.HTTPError as exc: + details = exc.read().decode("utf-8", errors="ignore") + raise LLMRequestError(f"模型接口调用失败:HTTP {exc.code} {details}") from exc + except error.URLError as exc: + raise LLMRequestError(f"模型接口调用失败:{exc.reason}") from exc + + try: + return data["choices"][0]["message"]["content"].strip() + except (KeyError, IndexError, TypeError) as exc: + raise LLMRequestError("模型接口返回格式不符合预期。") from exc + + +def stream_reply(conversation, user_message: str, knowledge_context: str = ""): + """Streams incremental assistant text from the SiliconFlow chat endpoint.""" + + if not settings.LLM_API_KEY: + raise LLMConfigurationError("缺少 LLM_API_KEY 配置。") + if not settings.LLM_MODEL: + raise LLMConfigurationError("缺少 LLM_MODEL 配置。") + + payload = { + "model": settings.LLM_MODEL, + "messages": build_messages(conversation, user_message, knowledge_context=knowledge_context), + "temperature": 0.3, + "stream": True, + } + body = json.dumps(payload).encode("utf-8") + endpoint = f"{settings.LLM_BASE_URL.rstrip('/')}/chat/completions" + + http_request = request.Request( + endpoint, + data=body, + headers={ + "Authorization": f"Bearer {settings.LLM_API_KEY}", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with request.urlopen(http_request, timeout=300) as response: + for raw_line in response: + line = raw_line.decode("utf-8", errors="ignore").strip() + if not line or not line.startswith("data:"): + continue + data = line[5:].strip() + if data == "[DONE]": + break + try: + payload = json.loads(data) + except json.JSONDecodeError: + logger.warning("Skipping malformed LLM stream data", extra={"data": data[:200]}) + continue + delta = ( + payload.get("choices", [{}])[0] + .get("delta", {}) + .get("content", "") + ) + if delta: + yield delta + except error.HTTPError as exc: + details = exc.read().decode("utf-8", errors="ignore") + raise LLMRequestError(f"模型接口调用失败:HTTP {exc.code} {details}") from exc + except error.URLError as exc: + raise LLMRequestError(f"模型接口调用失败:{exc.reason}") from exc + + +def build_messages(conversation, latest_user_message: str, knowledge_context: str = "") -> list[dict[str, str]]: + """Builds system and conversation history messages for the provider call.""" + + messages = [{"role": "system", "content": system_prompt()}] + if knowledge_context.strip(): + messages.append( + { + "role": "system", + "content": ( + "以下是全局知识库检索到的材料片段。回答用户时优先依据这些片段;" + "如果片段不足以支持结论,请明确说明信息不足,不要编造。\n\n" + f"{knowledge_context.strip()}" + ), + } + ) + + for message in conversation.messages.all(): + messages.append({"role": message.role, "content": message.content}) + + if not conversation.messages.filter(role="user", content=latest_user_message.strip()).exists(): + messages.append({"role": "user", "content": latest_user_message.strip()}) + + return messages + + +def system_prompt() -> str: + """Defines the initial assistant behavior for the review workspace.""" + + return ( + "你是“审核智能体”,服务于体外诊断试剂临床注册文件准备与审核场景。" + "你的回答要专业、简洁、结构清楚,优先帮助用户完成法规核查、说明书审核、" + "风险识别、资料补充建议和审评思路梳理。" + "当信息不足时,明确指出缺失信息,并给出下一步建议。" + "除非用户明确要求英文,否则始终使用中文回答。" + ) diff --git a/review_agent/logging_filters.py b/review_agent/logging_filters.py new file mode 100644 index 0000000..b340ea7 --- /dev/null +++ b/review_agent/logging_filters.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import logging +import re + + +class SuppressWorkflowStatusPollFilter(logging.Filter): + """Hides noisy workflow status polling access logs from runserver output.""" + + STATUS_POLL_PATTERN = re.compile( + r'"GET /api/review-agent/(?:file-summary|regulatory-review)/\d+/status/ HTTP/[0-9.]+" 200 ' + ) + + def filter(self, record: logging.LogRecord) -> bool: + return not self.STATUS_POLL_PATTERN.search(record.getMessage()) diff --git a/review_agent/management/__init__.py b/review_agent/management/__init__.py new file mode 100644 index 0000000..bd9bed7 --- /dev/null +++ b/review_agent/management/__init__.py @@ -0,0 +1 @@ +"""Management command package for review_agent.""" diff --git a/review_agent/management/commands/__init__.py b/review_agent/management/commands/__init__.py new file mode 100644 index 0000000..823f3f6 --- /dev/null +++ b/review_agent/management/commands/__init__.py @@ -0,0 +1 @@ +"""Management commands for review_agent.""" diff --git a/review_agent/management/commands/feishu_question_simulate.py b/review_agent/management/commands/feishu_question_simulate.py new file mode 100644 index 0000000..1220ed4 --- /dev/null +++ b/review_agent/management/commands/feishu_question_simulate.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError + +from review_agent.feishu_questions.service import answer_question + + +class Command(BaseCommand): + help = "Simulate a reserved Feishu question against local workflow data." + + def add_arguments(self, parser): + parser.add_argument("--username", required=True, help="System username used as asker.") + parser.add_argument("question", help="Question text, for example: 查最新法规核查") + + def handle(self, *args, **options): + user = get_user_model().objects.filter(username=options["username"]).first() + if not user: + raise CommandError(f"用户不存在:{options['username']}") + result = answer_question(user, options["question"]) + self.stdout.write(result.get("answer_summary") or "无可返回摘要。") diff --git a/review_agent/management/commands/regulatory_rag_build.py b/review_agent/management/commands/regulatory_rag_build.py new file mode 100644 index 0000000..c2263aa --- /dev/null +++ b/review_agent/management/commands/regulatory_rag_build.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pathlib import Path + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + +from review_agent.regulatory_review.services.rag_embedding import get_embedding_provider +from review_agent.regulatory_review.services.rag_index import build_chroma_index +from review_agent.regulatory_review.services.rule_loader import load_rule_file + + +class Command(BaseCommand): + help = "构建 NMPA 法规材料本地 ChromaDB RAG 索引。" + + def add_arguments(self, parser): + parser.add_argument("--provider", default=None, help="覆盖 REGULATORY_RAG_PROVIDER。") + + def handle(self, *args, **options): + rule_set = load_rule_file() + source_dir = Path(settings.BASE_DIR) / rule_set["source_material_dir"] + if not source_dir.exists(): + raise CommandError(f"法规材料目录不存在:{source_dir}") + try: + provider = get_embedding_provider(options["provider"]) + count = build_chroma_index(source_dir=source_dir, embedding_provider=provider, reset=True) + except Exception as exc: + raise CommandError(str(exc)) from exc + self.stdout.write( + self.style.SUCCESS( + f"已构建法规 RAG 索引:collection={settings.REGULATORY_RAG_COLLECTION}, chunks={count}" + ) + ) diff --git a/review_agent/management/commands/regulatory_rules_check.py b/review_agent/management/commands/regulatory_rules_check.py new file mode 100644 index 0000000..17e83af --- /dev/null +++ b/review_agent/management/commands/regulatory_rules_check.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from django.core.management.base import BaseCommand + +from review_agent.regulatory_review.services.rule_loader import check_rule_version + + +class Command(BaseCommand): + help = "检查 NMPA 法规核查 YAML 规则与数据库版本记录。" + + def add_arguments(self, parser): + parser.add_argument( + "--no-create", + action="store_true", + help="缺少数据库记录时只报告 missing,不创建记录。", + ) + + def handle(self, *args, **options): + result = check_rule_version(update_missing=not options["no_create"]) + self.stdout.write( + f"{result.code}: {result.status}; yaml_hash={result.current_hash}; " + f"db_hash={result.database_hash or '-'}; path={result.path}" + ) + if result.status == "mismatch": + self.stdout.write( + self.style.WARNING("YAML 与数据库记录不一致,请人工确认后更新规则版本记录。") + ) diff --git a/review_agent/management/commands/send_test_feishu_notification.py b/review_agent/management/commands/send_test_feishu_notification.py new file mode 100644 index 0000000..cc85932 --- /dev/null +++ b/review_agent/management/commands/send_test_feishu_notification.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError + +from review_agent.notifications.context import NotificationContext +from review_agent.notifications.dispatcher import dispatch_workflow_notification + + +class Command(BaseCommand): + help = "Send a manual Feishu test notification through the unified dispatcher." + + def add_arguments(self, parser): + parser.add_argument("--username", required=True, help="System username used as trigger user.") + + def handle(self, *args, **options): + username = options["username"] + user = get_user_model().objects.filter(username=username).first() + if not user: + raise CommandError(f"用户不存在:{username}") + + context = NotificationContext( + workflow_type="manual_test", + workflow_name="飞书测试", + workflow_batch_id=user.pk, + workflow_batch_no=f"MANUAL-{user.pk}", + workflow_status="success", + trigger_user_id=user.pk, + trigger_username=user.get_username(), + title="飞书测试通知", + summary_lines=("这是一条本地手动测试通知。",), + next_step="确认飞书个人账号是否收到消息", + result_path="/", + ) + record = dispatch_workflow_notification(context) + self.stdout.write(f"send_status={record.send_status}") + self.stdout.write(f"target={record.target}") + if record.error_message: + self.stdout.write(f"error={record.error_message}") diff --git a/review_agent/migrations/0001_initial.py b/review_agent/migrations/0001_initial.py new file mode 100644 index 0000000..de6cc23 --- /dev/null +++ b/review_agent/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 5.2.14 on 2026-06-04 15:27 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Conversation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(blank=True, max_length=120)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="review_conversations", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-updated_at", "-id"], + }, + ), + migrations.CreateModel( + name="Message", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "role", + models.CharField( + choices=[("user", "用户"), ("assistant", "助手")], max_length=20 + ), + ), + ("content", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "conversation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="review_agent.conversation", + ), + ), + ], + options={ + "ordering": ["created_at", "id"], + }, + ), + ] diff --git a/review_agent/migrations/0002_fileattachment_filesummarybatch_exportedsummaryfile_and_more.py b/review_agent/migrations/0002_fileattachment_filesummarybatch_exportedsummaryfile_and_more.py new file mode 100644 index 0000000..10ef36a --- /dev/null +++ b/review_agent/migrations/0002_fileattachment_filesummarybatch_exportedsummaryfile_and_more.py @@ -0,0 +1,481 @@ +# Generated by Django 5.2.14 on 2026-06-05 17:09 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("review_agent", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="FileAttachment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("original_name", models.CharField(max_length=255)), + ("version_no", models.PositiveIntegerField(default=1)), + ("is_active", models.BooleanField(default=True)), + ("storage_path", models.CharField(max_length=500)), + ("file_size", models.BigIntegerField(default=0)), + ( + "content_type", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "upload_status", + models.CharField( + choices=[ + ("uploaded", "已上传"), + ("bound", "已绑定"), + ("deleted", "已删除"), + ], + default="uploaded", + max_length=20, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "conversation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="file_attachments", + to="review_agent.conversation", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="review_file_attachments", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "ra_file_attachment", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="FileSummaryBatch", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("batch_no", models.CharField(max_length=64, unique=True)), + ( + "product_name", + models.CharField(blank=True, default="", max_length=200), + ), + ( + "status", + models.CharField( + choices=[ + ("pending", "待执行"), + ("running", "执行中"), + ("success", "成功"), + ("failed", "失败"), + ], + default="pending", + max_length=20, + ), + ), + ("total_files", models.IntegerField(default=0)), + ("supported_files", models.IntegerField(default=0)), + ("success_files", models.IntegerField(default=0)), + ("failed_files", models.IntegerField(default=0)), + ("unsupported_files", models.IntegerField(default=0)), + ("uncertain_files", models.IntegerField(default=0)), + ("total_pages", models.IntegerField(default=0)), + ("work_dir", models.CharField(blank=True, default="", max_length=500)), + ("error_message", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("finished_at", models.DateTimeField(blank=True, null=True)), + ( + "conversation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="file_summary_batches", + to="review_agent.conversation", + ), + ), + ( + "trigger_message", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="triggered_file_summary_batches", + to="review_agent.message", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="review_file_summary_batches", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "ra_file_summary_batch", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="ExportedSummaryFile", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "export_type", + models.CharField( + choices=[("markdown", "Markdown"), ("excel", "Excel")], + max_length=20, + ), + ), + ("file_name", models.CharField(max_length=255)), + ("storage_path", models.CharField(max_length=500)), + ( + "status", + models.CharField( + choices=[("success", "成功"), ("failed", "失败")], + default="success", + max_length=20, + ), + ), + ("error_message", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "batch", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="exports", + to="review_agent.filesummarybatch", + ), + ), + ], + options={ + "db_table": "ra_exported_summary_file", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="FileSummaryBatchAttachment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "source_role", + models.CharField( + choices=[("archive", "压缩包"), ("multi_file", "多文件")], + default="multi_file", + max_length=20, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "attachment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="batch_bindings", + to="review_agent.fileattachment", + ), + ), + ( + "batch", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="batch_attachments", + to="review_agent.filesummarybatch", + ), + ), + ], + options={ + "db_table": "ra_file_summary_batch_attachment", + }, + ), + migrations.CreateModel( + name="FileSummaryItem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("file_index", models.PositiveIntegerField()), + ( + "directory_level", + models.CharField(blank=True, default="", max_length=300), + ), + ("file_name", models.CharField(max_length=255)), + ("file_type", models.CharField(max_length=20)), + ("relative_path", models.CharField(max_length=500)), + ("storage_path", models.CharField(max_length=500)), + ("page_count", models.IntegerField(blank=True, null=True)), + ( + "statistics_status", + models.CharField( + choices=[ + ("success", "成功"), + ("failed", "失败"), + ("unsupported", "不支持"), + ("uncertain", "不确定"), + ("skipped", "跳过"), + ], + default="skipped", + max_length=20, + ), + ), + ("retry_count", models.PositiveIntegerField(default=0)), + ("error_message", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "batch", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + to="review_agent.filesummarybatch", + ), + ), + ], + options={ + "db_table": "ra_file_summary_item", + "ordering": ["file_index", "id"], + }, + ), + migrations.CreateModel( + name="WorkflowEvent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("event_type", models.CharField(max_length=40)), + ("payload", models.JSONField(default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "batch", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="events", + to="review_agent.filesummarybatch", + ), + ), + ], + options={ + "db_table": "ra_workflow_event", + "ordering": ["id"], + }, + ), + migrations.CreateModel( + name="WorkflowNodeRun", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("node_code", models.CharField(max_length=40)), + ("node_name", models.CharField(max_length=80)), + ( + "status", + models.CharField( + choices=[ + ("pending", "等待中"), + ("running", "执行中"), + ("retrying", "重试中"), + ("success", "成功"), + ("failed", "失败"), + ("skipped", "跳过"), + ], + default="pending", + max_length=20, + ), + ), + ("progress", models.PositiveIntegerField(default=0)), + ("message", models.TextField(blank=True, default="")), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("finished_at", models.DateTimeField(blank=True, null=True)), + ( + "batch", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="node_runs", + to="review_agent.filesummarybatch", + ), + ), + ], + options={ + "db_table": "ra_workflow_node_run", + }, + ), + migrations.AddIndex( + model_name="fileattachment", + index=models.Index( + fields=["conversation", "created_at"], + name="idx_ra_attachment_conv_created", + ), + ), + migrations.AddIndex( + model_name="fileattachment", + index=models.Index( + fields=["user", "created_at"], name="idx_ra_attachment_user_created" + ), + ), + migrations.AddIndex( + model_name="fileattachment", + index=models.Index( + fields=["conversation", "original_name", "is_active"], + name="idx_ra_attachment_active", + ), + ), + migrations.AddConstraint( + model_name="fileattachment", + constraint=models.UniqueConstraint( + fields=("conversation", "original_name", "version_no"), + name="uq_ra_attachment_conv_name_version", + ), + ), + migrations.AddIndex( + model_name="filesummarybatch", + index=models.Index( + fields=["conversation", "created_at"], name="idx_ra_batch_conv_created" + ), + ), + migrations.AddIndex( + model_name="filesummarybatch", + index=models.Index( + fields=["user", "created_at"], name="idx_ra_batch_user_created" + ), + ), + migrations.AddIndex( + model_name="filesummarybatch", + index=models.Index( + fields=["status", "created_at"], name="idx_ra_batch_status" + ), + ), + migrations.AddIndex( + model_name="exportedsummaryfile", + index=models.Index( + fields=["batch", "export_type"], name="idx_ra_export_batch_type" + ), + ), + migrations.AddIndex( + model_name="exportedsummaryfile", + index=models.Index( + fields=["batch", "created_at"], name="idx_ra_export_batch_created" + ), + ), + migrations.AddIndex( + model_name="filesummarybatchattachment", + index=models.Index( + fields=["batch", "created_at"], name="idx_ra_batch_attachment_batch" + ), + ), + migrations.AddIndex( + model_name="filesummarybatchattachment", + index=models.Index(fields=["attachment"], name="idx_ra_batch_attach_file"), + ), + migrations.AddConstraint( + model_name="filesummarybatchattachment", + constraint=models.UniqueConstraint( + fields=("batch", "attachment"), name="uq_ra_batch_attachment" + ), + ), + migrations.AddIndex( + model_name="filesummaryitem", + index=models.Index( + fields=["batch", "file_index"], name="idx_ra_item_batch_index" + ), + ), + migrations.AddIndex( + model_name="filesummaryitem", + index=models.Index( + fields=["batch", "statistics_status"], name="idx_ra_item_batch_status" + ), + ), + migrations.AddIndex( + model_name="filesummaryitem", + index=models.Index( + fields=["batch", "file_type"], name="idx_ra_item_batch_type" + ), + ), + migrations.AddConstraint( + model_name="filesummaryitem", + constraint=models.UniqueConstraint( + fields=("batch", "relative_path"), name="uq_ra_item_batch_relative_path" + ), + ), + migrations.AddIndex( + model_name="workflowevent", + index=models.Index(fields=["batch", "id"], name="idx_ra_event_batch_id"), + ), + migrations.AddIndex( + model_name="workflowevent", + index=models.Index( + fields=["batch", "created_at"], name="idx_ra_event_batch_created" + ), + ), + migrations.AddIndex( + model_name="workflownoderun", + index=models.Index( + fields=["batch", "status"], name="idx_ra_node_batch_status" + ), + ), + migrations.AddConstraint( + model_name="workflownoderun", + constraint=models.UniqueConstraint( + fields=("batch", "node_code"), name="uq_ra_node_batch_code" + ), + ), + ] diff --git a/review_agent/migrations/0003_regulatoryartifact_regulatoryissue_and_more.py b/review_agent/migrations/0003_regulatoryartifact_regulatoryissue_and_more.py new file mode 100644 index 0000000..606c95b --- /dev/null +++ b/review_agent/migrations/0003_regulatoryartifact_regulatoryissue_and_more.py @@ -0,0 +1,479 @@ +# Generated by Django 5.2.14 on 2026-06-06 16:22 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "review_agent", + "0002_fileattachment_filesummarybatch_exportedsummaryfile_and_more", + ), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="RegulatoryArtifact", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "artifact_type", + models.CharField( + choices=[ + ("markdown", "Markdown"), + ("excel", "Excel"), + ("json", "JSON"), + ("text", "文本"), + ], + max_length=20, + ), + ), + ("name", models.CharField(max_length=160)), + ("storage_path", models.CharField(max_length=500)), + ( + "content_hash", + models.CharField(blank=True, default="", max_length=128), + ), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={ + "db_table": "ra_regulatory_artifact", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="RegulatoryIssue", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("rule_code", models.CharField(blank=True, default="", max_length=120)), + ( + "category", + models.CharField( + choices=[ + ("completeness", "完整性"), + ("structure", "章节"), + ("consistency", "一致性"), + ("rag", "法规依据"), + ], + max_length=40, + ), + ), + ( + "severity", + models.CharField( + choices=[ + ("blocking", "阻断项"), + ("high", "高风险"), + ("medium", "中风险"), + ("low", "低风险"), + ("info", "提示"), + ], + max_length=20, + ), + ), + ("title", models.CharField(max_length=255)), + ("detail", models.TextField(blank=True, default="")), + ("suggestion", models.TextField(blank=True, default="")), + ( + "status", + models.CharField( + choices=[ + ("open", "待处理"), + ("resolved", "已整改"), + ("accepted", "已接受"), + ], + default="open", + max_length=20, + ), + ), + ("evidence", models.JSONField(blank=True, default=dict)), + ("citations", models.JSONField(blank=True, default=list)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "ra_regulatory_issue", + "ordering": ["severity", "id"], + }, + ), + migrations.CreateModel( + name="RegulatoryNotificationRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "channel", + models.CharField( + choices=[("mock", "模拟"), ("feishu", "飞书")], + default="mock", + max_length=20, + ), + ), + ("target", models.CharField(blank=True, default="", max_length=160)), + ("payload", models.JSONField(blank=True, default=dict)), + ( + "status", + models.CharField( + choices=[ + ("pending", "待发送"), + ("sent", "已发送"), + ("failed", "失败"), + ], + default="pending", + max_length=20, + ), + ), + ("error_message", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("sent_at", models.DateTimeField(blank=True, null=True)), + ], + options={ + "db_table": "ra_regulatory_notification_record", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="RegulatoryReviewBatch", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("batch_no", models.CharField(max_length=64, unique=True)), + ( + "status", + models.CharField( + choices=[ + ("pending", "待执行"), + ("running", "执行中"), + ("success", "成功"), + ("failed", "失败"), + ], + default="pending", + max_length=20, + ), + ), + ("risk_summary", models.JSONField(blank=True, default=dict)), + ("work_dir", models.CharField(blank=True, default="", max_length=500)), + ("error_message", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("finished_at", models.DateTimeField(blank=True, null=True)), + ], + options={ + "db_table": "ra_regulatory_review_batch", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="RegulatoryRuleVersion", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("code", models.CharField(max_length=80, unique=True)), + ("name", models.CharField(max_length=160)), + ("yaml_path", models.CharField(max_length=500)), + ("yaml_hash", models.CharField(max_length=128)), + ( + "rag_collection", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "rag_index_version", + models.CharField(blank=True, default="", max_length=80), + ), + ( + "rag_index_hash", + models.CharField(blank=True, default="", max_length=128), + ), + ( + "status", + models.CharField( + choices=[ + ("active", "启用"), + ("outdated", "待更新"), + ("disabled", "停用"), + ], + default="active", + max_length=20, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "ra_regulatory_rule_version", + "ordering": ["-updated_at", "-id"], + }, + ), + migrations.AddField( + model_name="exportedsummaryfile", + name="export_category", + field=models.CharField(blank=True, default="summary", max_length=40), + ), + migrations.AddField( + model_name="exportedsummaryfile", + name="workflow_batch_id", + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="exportedsummaryfile", + name="workflow_type", + field=models.CharField(blank=True, default="file_summary", max_length=40), + ), + migrations.AddField( + model_name="workflowevent", + name="conversation", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="workflow_events", + to="review_agent.conversation", + ), + ), + migrations.AddField( + model_name="workflowevent", + name="workflow_batch_id", + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="workflowevent", + name="workflow_type", + field=models.CharField(blank=True, default="file_summary", max_length=40), + ), + migrations.AddField( + model_name="workflownoderun", + name="node_group", + field=models.CharField(blank=True, default="file_summary", max_length=40), + ), + migrations.AddField( + model_name="workflownoderun", + name="workflow_batch_id", + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="workflownoderun", + name="workflow_type", + field=models.CharField(blank=True, default="file_summary", max_length=40), + ), + migrations.AlterField( + model_name="exportedsummaryfile", + name="export_type", + field=models.CharField( + choices=[ + ("markdown", "Markdown"), + ("excel", "Excel"), + ("json", "JSON"), + ], + max_length=20, + ), + ), + migrations.AlterField( + model_name="workflowevent", + name="batch", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="events", + to="review_agent.filesummarybatch", + ), + ), + migrations.AlterField( + model_name="workflownoderun", + name="batch", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="node_runs", + to="review_agent.filesummarybatch", + ), + ), + migrations.AddIndex( + model_name="exportedsummaryfile", + index=models.Index( + fields=["workflow_type", "workflow_batch_id"], + name="idx_ra_export_workflow", + ), + ), + migrations.AddIndex( + model_name="workflowevent", + index=models.Index( + fields=["workflow_type", "workflow_batch_id", "id"], + name="idx_ra_event_workflow_id", + ), + ), + migrations.AddIndex( + model_name="workflownoderun", + index=models.Index( + fields=["workflow_type", "workflow_batch_id"], + name="idx_ra_node_workflow", + ), + ), + migrations.AddField( + model_name="regulatoryreviewbatch", + name="conversation", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="regulatory_review_batches", + to="review_agent.conversation", + ), + ), + migrations.AddField( + model_name="regulatoryreviewbatch", + name="source_summary_batch", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="regulatory_review_batches", + to="review_agent.filesummarybatch", + ), + ), + migrations.AddField( + model_name="regulatoryreviewbatch", + name="trigger_message", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="triggered_regulatory_batches", + to="review_agent.message", + ), + ), + migrations.AddField( + model_name="regulatoryreviewbatch", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="review_regulatory_batches", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="regulatorynotificationrecord", + name="batch", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="review_agent.regulatoryreviewbatch", + ), + ), + migrations.AddField( + model_name="regulatoryissue", + name="batch", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issues", + to="review_agent.regulatoryreviewbatch", + ), + ), + migrations.AddField( + model_name="regulatoryartifact", + name="batch", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="artifacts", + to="review_agent.regulatoryreviewbatch", + ), + ), + migrations.AddIndex( + model_name="regulatoryruleversion", + index=models.Index( + fields=["code", "status"], name="idx_ra_rule_code_status" + ), + ), + migrations.AddField( + model_name="regulatoryreviewbatch", + name="rule_version", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="review_batches", + to="review_agent.regulatoryruleversion", + ), + ), + migrations.AddIndex( + model_name="regulatorynotificationrecord", + index=models.Index( + fields=["batch", "status"], name="idx_ra_rr_notify_status" + ), + ), + migrations.AddIndex( + model_name="regulatoryissue", + index=models.Index( + fields=["batch", "severity"], name="idx_ra_rr_issue_severity" + ), + ), + migrations.AddIndex( + model_name="regulatoryissue", + index=models.Index( + fields=["batch", "category"], name="idx_ra_rr_issue_category" + ), + ), + migrations.AddIndex( + model_name="regulatoryartifact", + index=models.Index( + fields=["batch", "artifact_type"], name="idx_ra_rr_artifact_type" + ), + ), + migrations.AddIndex( + model_name="regulatoryreviewbatch", + index=models.Index( + fields=["conversation", "created_at"], name="idx_ra_rr_batch_conv" + ), + ), + migrations.AddIndex( + model_name="regulatoryreviewbatch", + index=models.Index( + fields=["user", "created_at"], name="idx_ra_rr_batch_user" + ), + ), + migrations.AddIndex( + model_name="regulatoryreviewbatch", + index=models.Index( + fields=["status", "created_at"], name="idx_ra_rr_batch_status" + ), + ), + ] diff --git a/review_agent/migrations/0004_regulatoryreviewbatch_condition_json_and_more.py b/review_agent/migrations/0004_regulatoryreviewbatch_condition_json_and_more.py new file mode 100644 index 0000000..f4b7ac7 --- /dev/null +++ b/review_agent/migrations/0004_regulatoryreviewbatch_condition_json_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 5.2.14 on 2026-06-07 01:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("review_agent", "0003_regulatoryartifact_regulatoryissue_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="regulatoryreviewbatch", + name="condition_json", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name="regulatoryreviewbatch", + name="status", + field=models.CharField( + choices=[ + ("pending", "待执行"), + ("running", "执行中"), + ("waiting_user", "等待用户确认"), + ("success", "成功"), + ("failed", "失败"), + ], + default="pending", + max_length=20, + ), + ), + migrations.AlterField( + model_name="workflownoderun", + name="status", + field=models.CharField( + choices=[ + ("pending", "等待中"), + ("running", "执行中"), + ("waiting_user", "等待用户确认"), + ("retrying", "重试中"), + ("success", "成功"), + ("failed", "失败"), + ("skipped", "跳过"), + ], + default="pending", + max_length=20, + ), + ), + ] diff --git a/review_agent/migrations/0005_alter_regulatoryissue_status.py b/review_agent/migrations/0005_alter_regulatoryissue_status.py new file mode 100644 index 0000000..d23d744 --- /dev/null +++ b/review_agent/migrations/0005_alter_regulatoryissue_status.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.14 on 2026-06-07 01:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("review_agent", "0004_regulatoryreviewbatch_condition_json_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="regulatoryissue", + name="status", + field=models.CharField( + choices=[ + ("open", "待处理"), + ("resolved", "已整改"), + ("accepted", "已接受"), + ("review_passed", "复核通过"), + ("review_failed", "复核未通过"), + ], + default="open", + max_length=20, + ), + ), + ] diff --git a/review_agent/migrations/0006_alter_exportedsummaryfile_export_type_and_more.py b/review_agent/migrations/0006_alter_exportedsummaryfile_export_type_and_more.py new file mode 100644 index 0000000..b7821f1 --- /dev/null +++ b/review_agent/migrations/0006_alter_exportedsummaryfile_export_type_and_more.py @@ -0,0 +1,353 @@ +# Generated by Django 5.2.14 on 2026-06-07 10:19 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("review_agent", "0005_alter_regulatoryissue_status"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="exportedsummaryfile", + name="export_type", + field=models.CharField( + choices=[ + ("markdown", "Markdown"), + ("excel", "Excel"), + ("json", "JSON"), + ("word", "Word"), + ("pdf", "PDF"), + ], + max_length=20, + ), + ), + migrations.CreateModel( + name="ApplicationFormFillBatch", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("batch_no", models.CharField(max_length=64, unique=True)), + ( + "status", + models.CharField( + choices=[ + ("pending", "待执行"), + ("running", "执行中"), + ("waiting_user", "等待用户"), + ("success", "成功"), + ("partial_success", "部分成功"), + ("failed", "失败"), + ("cancelled", "已取消"), + ], + default="pending", + max_length=30, + ), + ), + ("requested_templates", models.JSONField(blank=True, default=list)), + ("selected_templates", models.JSONField(blank=True, default=list)), + ("output_types", models.JSONField(blank=True, default=list)), + ( + "registration_type", + models.CharField(blank=True, default="", max_length=80), + ), + ( + "registration_type_source", + models.CharField( + choices=[ + ("user_message", "用户话语"), + ("regulatory_batch", "法规核查批次"), + ("file_extract", "文件抽取"), + ("unknown", "未知"), + ], + default="unknown", + max_length=40, + ), + ), + ( + "product_name", + models.CharField(blank=True, default="", max_length=200), + ), + ("conflict_summary", models.JSONField(blank=True, default=list)), + ("risk_notes", models.JSONField(blank=True, default=list)), + ( + "template_config_version", + models.CharField(blank=True, default="", max_length=80), + ), + ( + "template_config_hash", + models.CharField(blank=True, default="", max_length=128), + ), + ("work_dir", models.CharField(blank=True, default="", max_length=500)), + ("error_message", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("finished_at", models.DateTimeField(blank=True, null=True)), + ("archived_at", models.DateTimeField(blank=True, null=True)), + ("is_deleted", models.BooleanField(default=False)), + ( + "conversation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="application_form_fill_batches", + to="review_agent.conversation", + ), + ), + ( + "source_regulatory_batch", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="application_form_fill_batches", + to="review_agent.regulatoryreviewbatch", + ), + ), + ( + "source_summary_batch", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="application_form_fill_batches", + to="review_agent.filesummarybatch", + ), + ), + ( + "trigger_message", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="triggered_application_form_fill_batches", + to="review_agent.message", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="review_application_form_fill_batches", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "ra_application_form_fill_batch", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="ApplicationFormFillArtifact", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "artifact_type", + models.CharField( + choices=[ + ("template_copy", "模板副本"), + ("field_extract_result", "字段抽取结果"), + ("merged_fields", "字段合并结果"), + ("traceability", "追溯清单"), + ("filled_template", "已填模板"), + ("notification_record", "通知记录"), + ], + max_length=60, + ), + ), + ( + "file_format", + models.CharField( + choices=[ + ("json", "JSON"), + ("excel", "Excel"), + ("docx", "DOCX"), + ("pdf", "PDF"), + ("markdown", "Markdown"), + ], + max_length=20, + ), + ), + ("name", models.CharField(max_length=160)), + ("file_name", models.CharField(max_length=255)), + ("storage_path", models.CharField(max_length=500)), + ("file_size", models.BigIntegerField(default=0)), + ( + "content_hash", + models.CharField(blank=True, default="", max_length=128), + ), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "created_by_node", + models.CharField(blank=True, default="", max_length=60), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("is_deleted", models.BooleanField(default=False)), + ( + "batch", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="artifacts", + to="review_agent.applicationformfillbatch", + ), + ), + ], + options={ + "db_table": "ra_application_form_fill_artifact", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="ApplicationFormFillNotificationRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "channel", + models.CharField( + choices=[ + ("feishu_cli", "飞书 CLI"), + ("feishu_api", "飞书 API"), + ("mock", "模拟"), + ], + default="mock", + max_length=30, + ), + ), + ("template_codes", models.JSONField(blank=True, default=list)), + ("export_ids", models.JSONField(blank=True, default=list)), + ("message_summary", models.TextField(blank=True, default="")), + ( + "send_status", + models.CharField( + choices=[ + ("pending", "待发送"), + ("success", "成功"), + ("failed", "失败"), + ], + default="pending", + max_length=20, + ), + ), + ("retry_count", models.PositiveIntegerField(default=0)), + ( + "external_message_id", + models.CharField(blank=True, default="", max_length=120), + ), + ("error_message", models.TextField(blank=True, default="")), + ("sent_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("is_deleted", models.BooleanField(default=False)), + ( + "batch", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="review_agent.applicationformfillbatch", + ), + ), + ( + "recipient", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="application_form_fill_notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "ra_application_form_fill_notification_record", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.AddIndex( + model_name="applicationformfillbatch", + index=models.Index( + fields=["conversation", "status"], name="idx_ra_aff_batch_conv_status" + ), + ), + migrations.AddIndex( + model_name="applicationformfillbatch", + index=models.Index( + fields=["source_summary_batch"], name="idx_ra_aff_batch_summary" + ), + ), + migrations.AddIndex( + model_name="applicationformfillbatch", + index=models.Index( + fields=["source_regulatory_batch"], name="idx_ra_aff_batch_regulatory" + ), + ), + migrations.AddIndex( + model_name="applicationformfillbatch", + index=models.Index( + fields=["user", "created_at"], name="idx_ra_aff_batch_user_created" + ), + ), + migrations.AddIndex( + model_name="applicationformfillbatch", + index=models.Index(fields=["created_at"], name="idx_ra_aff_batch_created"), + ), + migrations.AddIndex( + model_name="applicationformfillartifact", + index=models.Index( + fields=["batch", "artifact_type"], name="idx_ra_aff_artifact_batch_type" + ), + ), + migrations.AddIndex( + model_name="applicationformfillartifact", + index=models.Index( + fields=["file_format"], name="idx_ra_aff_artifact_format" + ), + ), + migrations.AddIndex( + model_name="applicationformfillartifact", + index=models.Index( + fields=["created_at"], name="idx_ra_aff_artifact_created" + ), + ), + migrations.AddIndex( + model_name="applicationformfillnotificationrecord", + index=models.Index( + fields=["batch", "created_at"], name="idx_ra_aff_notify_batch" + ), + ), + migrations.AddIndex( + model_name="applicationformfillnotificationrecord", + index=models.Index( + fields=["recipient", "send_status"], name="idx_ra_aff_notify_recipient" + ), + ), + migrations.AddIndex( + model_name="applicationformfillnotificationrecord", + index=models.Index( + fields=["send_status", "retry_count"], name="idx_ra_aff_notify_status" + ), + ), + ] diff --git a/review_agent/migrations/0007_feishuaccesstokencache_feishuusermapping_and_more.py b/review_agent/migrations/0007_feishuaccesstokencache_feishuusermapping_and_more.py new file mode 100644 index 0000000..dc1cb12 --- /dev/null +++ b/review_agent/migrations/0007_feishuaccesstokencache_feishuusermapping_and_more.py @@ -0,0 +1,352 @@ +# Generated by Django 5.2.14 on 2026-06-07 14:02 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("review_agent", "0006_alter_exportedsummaryfile_export_type_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="FeishuAccessTokenCache", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("app_id_hash", models.CharField(max_length=128, unique=True)), + ("tenant_access_token", models.TextField(blank=True, default="")), + ("expires_at", models.DateTimeField(blank=True, null=True)), + ("error_message", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "ra_feishu_access_token_cache", + "ordering": ["-updated_at", "-id"], + "indexes": [ + models.Index( + fields=["app_id_hash"], name="idx_ra_feishu_token_app" + ), + models.Index(fields=["expires_at"], name="idx_ra_feishu_token_exp"), + ], + }, + ), + migrations.CreateModel( + name="FeishuUserMapping", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "feishu_display_name", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "feishu_open_id", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "feishu_user_id", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "feishu_mobile", + models.CharField(blank=True, default="", max_length=40), + ), + ("is_active", models.BooleanField(default=True)), + ("remark", models.CharField(blank=True, default="", max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "system_user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="feishu_mapping", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "ra_feishu_user_mapping", + "ordering": ["system_user__username", "id"], + }, + ), + migrations.CreateModel( + name="FeishuQuestionLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "feishu_open_id", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "feishu_user_id", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "source_type", + models.CharField( + choices=[ + ("private_chat", "私聊"), + ("group_mention", "群聊 @"), + ("simulate", "本地模拟"), + ], + default="simulate", + max_length=30, + ), + ), + ( + "message_id", + models.CharField(blank=True, default="", max_length=120), + ), + ("question_text", models.TextField()), + ("intent", models.CharField(blank=True, default="", max_length=60)), + ("query_object", models.JSONField(blank=True, default=dict)), + ("answer_summary", models.TextField(blank=True, default="")), + ( + "permission_result", + models.CharField(blank=True, default="", max_length=40), + ), + ( + "status", + models.CharField( + choices=[ + ("success", "成功"), + ("failed", "失败"), + ("ignored", "忽略"), + ], + default="success", + max_length=30, + ), + ), + ("error_message", models.TextField(blank=True, default="")), + ("processed_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "system_user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="feishu_question_logs", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "feishu_mapping", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="question_logs", + to="review_agent.feishuusermapping", + ), + ), + ], + options={ + "db_table": "ra_feishu_question_log", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="WorkflowNotificationRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("workflow_type", models.CharField(max_length=40)), + ("workflow_batch_id", models.PositiveBigIntegerField()), + ("workflow_batch_no", models.CharField(max_length=80)), + ("workflow_status", models.CharField(max_length=40)), + ("dedupe_key", models.CharField(max_length=160)), + ( + "channel", + models.CharField( + choices=[ + ("mock", "模拟"), + ("disabled", "未启用"), + ("feishu_api", "飞书 API"), + ], + default="mock", + max_length=40, + ), + ), + ("target", models.CharField(blank=True, default="", max_length=160)), + ( + "at_display_name", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "at_identifier_type", + models.CharField(blank=True, default="", max_length=30), + ), + ( + "at_identifier_masked", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "send_status", + models.CharField( + choices=[ + ("pending", "待发送"), + ("success", "成功"), + ("failed", "失败"), + ("skipped_duplicate", "重复跳过"), + ("disabled", "未启用"), + ], + default="pending", + max_length=30, + ), + ), + ("message_title", models.CharField(max_length=200)), + ("message_summary", models.TextField(blank=True, default="")), + ( + "result_url", + models.CharField(blank=True, default="", max_length=500), + ), + ( + "external_message_id", + models.CharField(blank=True, default="", max_length=120), + ), + ("error_code", models.CharField(blank=True, default="", max_length=80)), + ("error_message", models.TextField(blank=True, default="")), + ( + "request_duration_ms", + models.PositiveIntegerField(blank=True, null=True), + ), + ("sent_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "feishu_mapping", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="notification_records", + to="review_agent.feishuusermapping", + ), + ), + ( + "trigger_user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workflow_notification_records", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "ra_workflow_notification_record", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.AddIndex( + model_name="feishuusermapping", + index=models.Index(fields=["is_active"], name="idx_ra_feishu_map_active"), + ), + migrations.AddIndex( + model_name="feishuusermapping", + index=models.Index( + fields=["feishu_open_id"], name="idx_ra_feishu_map_open" + ), + ), + migrations.AddIndex( + model_name="feishuusermapping", + index=models.Index( + fields=["feishu_user_id"], name="idx_ra_feishu_map_userid" + ), + ), + migrations.AddIndex( + model_name="feishuusermapping", + index=models.Index( + fields=["feishu_mobile"], name="idx_ra_feishu_map_mobile" + ), + ), + migrations.AddIndex( + model_name="feishuquestionlog", + index=models.Index( + fields=["system_user", "created_at"], + name="idx_ra_feishu_q_user_created", + ), + ), + migrations.AddIndex( + model_name="feishuquestionlog", + index=models.Index( + fields=["intent", "created_at"], name="idx_ra_feishu_q_intent" + ), + ), + migrations.AddIndex( + model_name="feishuquestionlog", + index=models.Index( + fields=["status", "created_at"], name="idx_ra_feishu_q_status" + ), + ), + migrations.AddIndex( + model_name="feishuquestionlog", + index=models.Index(fields=["message_id"], name="idx_ra_feishu_q_message"), + ), + migrations.AddIndex( + model_name="workflownotificationrecord", + index=models.Index( + fields=["workflow_type", "workflow_batch_id"], + name="idx_ra_notify_workflow", + ), + ), + migrations.AddIndex( + model_name="workflownotificationrecord", + index=models.Index( + fields=["trigger_user", "created_at"], name="idx_ra_notify_user_created" + ), + ), + migrations.AddIndex( + model_name="workflownotificationrecord", + index=models.Index( + fields=["send_status", "created_at"], name="idx_ra_notify_status" + ), + ), + migrations.AddIndex( + model_name="workflownotificationrecord", + index=models.Index( + fields=["workflow_batch_no"], name="idx_ra_notify_batch_no" + ), + ), + migrations.AddIndex( + model_name="workflownotificationrecord", + index=models.Index( + fields=["dedupe_key", "send_status"], name="idx_ra_notify_dedupe_status" + ), + ), + ] diff --git a/review_agent/migrations/0008_knowledgebasedocument.py b/review_agent/migrations/0008_knowledgebasedocument.py new file mode 100644 index 0000000..10b205f --- /dev/null +++ b/review_agent/migrations/0008_knowledgebasedocument.py @@ -0,0 +1,80 @@ +# Generated by Django 5.2.14 on 2026-06-08 11:58 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("review_agent", "0007_feishuaccesstokencache_feishuusermapping_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="KnowledgeBaseDocument", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("display_name", models.CharField(max_length=255)), + ("original_name", models.CharField(max_length=255)), + ("storage_path", models.CharField(max_length=500)), + ("file_size", models.BigIntegerField(default=0)), + ( + "content_type", + models.CharField(blank=True, default="", max_length=120), + ), + ("description", models.TextField(blank=True, default="")), + ( + "status", + models.CharField( + choices=[ + ("active", "启用"), + ("disabled", "停用"), + ("deleted", "已删除"), + ], + default="active", + max_length=20, + ), + ), + ("is_active", models.BooleanField(default=True)), + ("indexed_chunk_count", models.PositiveIntegerField(default=0)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="knowledge_base_documents", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "ra_knowledge_base_document", + "ordering": ["-updated_at", "-id"], + "indexes": [ + models.Index( + fields=["user", "status"], name="idx_ra_kb_doc_user_status" + ), + models.Index( + fields=["user", "created_at"], name="idx_ra_kb_doc_user_created" + ), + models.Index( + fields=["status", "updated_at"], + name="idx_ra_kb_doc_status_updated", + ), + ], + }, + ), + ] diff --git a/review_agent/migrations/0009_regulatoryinfopackageartifact_and_more.py b/review_agent/migrations/0009_regulatoryinfopackageartifact_and_more.py new file mode 100644 index 0000000..c36473d --- /dev/null +++ b/review_agent/migrations/0009_regulatoryinfopackageartifact_and_more.py @@ -0,0 +1,388 @@ +# Generated by Django 5.2.14 on 2026-06-10 11:12 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("review_agent", "0008_knowledgebasedocument"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="RegulatoryInfoPackageArtifact", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "artifact_type", + models.CharField( + choices=[ + ("template_copy", "模板副本"), + ("instruction_extract", "说明书抽取结果"), + ("field_extract_result", "字段抽取结果"), + ("merged_fields", "合并字段"), + ("generated_document", "生成文件"), + ("traceability", "追溯清单"), + ("zip_package", "ZIP包"), + ("notification_record", "通知记录"), + ], + max_length=60, + ), + ), + ( + "file_format", + models.CharField( + choices=[ + ("json", "JSON"), + ("excel", "Excel"), + ("docx", "DOCX"), + ("doc", "DOC"), + ("zip", "ZIP"), + ("markdown", "Markdown"), + ], + max_length=20, + ), + ), + ("name", models.CharField(max_length=160)), + ("file_name", models.CharField(max_length=255)), + ("storage_path", models.CharField(max_length=500)), + ("file_size", models.BigIntegerField(default=0)), + ( + "content_hash", + models.CharField(blank=True, default="", max_length=128), + ), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "created_by_node", + models.CharField(blank=True, default="", max_length=60), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("is_deleted", models.BooleanField(default=False)), + ], + options={ + "db_table": "ra_regulatory_info_package_artifact", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="RegulatoryInfoPackageBatch", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "source_summary_item_id", + models.PositiveBigIntegerField(blank=True, null=True), + ), + ("batch_no", models.CharField(max_length=64, unique=True)), + ( + "status", + models.CharField( + choices=[ + ("pending", "待执行"), + ("running", "执行中"), + ("waiting_user", "等待用户"), + ("success", "成功"), + ("partial_success", "部分成功"), + ("failed", "失败"), + ("cancelled", "已取消"), + ], + default="pending", + max_length=30, + ), + ), + ( + "source_file_name", + models.CharField(blank=True, default="", max_length=255), + ), + ( + "source_storage_path", + models.CharField(blank=True, default="", max_length=500), + ), + ( + "product_name", + models.CharField(blank=True, default="", max_length=200), + ), + ( + "output_zip_name", + models.CharField( + blank=True, + default="第1章 监管信息(预生成版).zip", + max_length=255, + ), + ), + ("generated_files", models.JSONField(blank=True, default=list)), + ("missing_fields", models.JSONField(blank=True, default=list)), + ("llm_only_fields", models.JSONField(blank=True, default=list)), + ("conflict_fields", models.JSONField(blank=True, default=list)), + ("risk_notes", models.JSONField(blank=True, default=list)), + ( + "template_config_version", + models.CharField(blank=True, default="", max_length=80), + ), + ( + "template_config_hash", + models.CharField(blank=True, default="", max_length=128), + ), + ("adapter_summary", models.JSONField(blank=True, default=dict)), + ("work_dir", models.CharField(blank=True, default="", max_length=500)), + ("error_message", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("finished_at", models.DateTimeField(blank=True, null=True)), + ("archived_at", models.DateTimeField(blank=True, null=True)), + ("is_deleted", models.BooleanField(default=False)), + ], + options={ + "db_table": "ra_regulatory_info_package_batch", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="RegulatoryInfoPackageNotificationRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "channel", + models.CharField( + choices=[ + ("feishu_cli", "飞书 CLI"), + ("feishu_api", "飞书 API"), + ("mock", "模拟"), + ], + default="mock", + max_length=30, + ), + ), + ("export_ids", models.JSONField(blank=True, default=list)), + ("message_summary", models.TextField(blank=True, default="")), + ( + "send_status", + models.CharField( + choices=[ + ("pending", "待发送"), + ("success", "成功"), + ("failed", "失败"), + ], + default="pending", + max_length=20, + ), + ), + ("retry_count", models.PositiveIntegerField(default=0)), + ( + "external_message_id", + models.CharField(blank=True, default="", max_length=120), + ), + ("error_message", models.TextField(blank=True, default="")), + ("sent_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("is_deleted", models.BooleanField(default=False)), + ], + options={ + "db_table": "ra_regulatory_info_package_notification_record", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.AlterField( + model_name="exportedsummaryfile", + name="batch", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="exports", + to="review_agent.filesummarybatch", + ), + ), + migrations.AlterField( + model_name="exportedsummaryfile", + name="export_type", + field=models.CharField( + choices=[ + ("markdown", "Markdown"), + ("excel", "Excel"), + ("json", "JSON"), + ("word", "Word"), + ("pdf", "PDF"), + ("zip", "ZIP"), + ], + max_length=20, + ), + ), + migrations.AddConstraint( + model_name="workflownoderun", + constraint=models.UniqueConstraint( + fields=("workflow_type", "workflow_batch_id", "node_code"), + name="uq_ra_node_workflow_batch_code", + ), + ), + migrations.AddField( + model_name="regulatoryinfopackagebatch", + name="conversation", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="regulatory_info_package_batches", + to="review_agent.conversation", + ), + ), + migrations.AddField( + model_name="regulatoryinfopackagebatch", + name="source_attachment", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="regulatory_info_package_batches", + to="review_agent.fileattachment", + ), + ), + migrations.AddField( + model_name="regulatoryinfopackagebatch", + name="source_summary_batch", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="regulatory_info_package_batches", + to="review_agent.filesummarybatch", + ), + ), + migrations.AddField( + model_name="regulatoryinfopackagebatch", + name="trigger_message", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="triggered_regulatory_info_package_batches", + to="review_agent.message", + ), + ), + migrations.AddField( + model_name="regulatoryinfopackagebatch", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="review_regulatory_info_package_batches", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="regulatoryinfopackageartifact", + name="batch", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="artifacts", + to="review_agent.regulatoryinfopackagebatch", + ), + ), + migrations.AddField( + model_name="regulatoryinfopackagenotificationrecord", + name="batch", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="review_agent.regulatoryinfopackagebatch", + ), + ), + migrations.AddField( + model_name="regulatoryinfopackagenotificationrecord", + name="recipient", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="regulatory_info_package_notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddIndex( + model_name="regulatoryinfopackagebatch", + index=models.Index( + fields=["conversation", "status"], name="idx_ra_rip_batch_conv_status" + ), + ), + migrations.AddIndex( + model_name="regulatoryinfopackagebatch", + index=models.Index( + fields=["user", "created_at"], name="idx_ra_rip_batch_user_created" + ), + ), + migrations.AddIndex( + model_name="regulatoryinfopackagebatch", + index=models.Index( + fields=["source_attachment"], name="idx_ra_rip_batch_attachment" + ), + ), + migrations.AddIndex( + model_name="regulatoryinfopackagebatch", + index=models.Index( + fields=["source_summary_batch"], name="idx_ra_rip_batch_summary" + ), + ), + migrations.AddIndex( + model_name="regulatoryinfopackagebatch", + index=models.Index(fields=["created_at"], name="idx_ra_rip_batch_created"), + ), + migrations.AddIndex( + model_name="regulatoryinfopackageartifact", + index=models.Index( + fields=["batch", "artifact_type"], name="idx_ra_rip_artifact_batch_type" + ), + ), + migrations.AddIndex( + model_name="regulatoryinfopackageartifact", + index=models.Index( + fields=["file_format"], name="idx_ra_rip_artifact_format" + ), + ), + migrations.AddIndex( + model_name="regulatoryinfopackageartifact", + index=models.Index( + fields=["created_at"], name="idx_ra_rip_artifact_created" + ), + ), + migrations.AddIndex( + model_name="regulatoryinfopackagenotificationrecord", + index=models.Index( + fields=["batch", "created_at"], name="idx_ra_rip_notify_batch" + ), + ), + migrations.AddIndex( + model_name="regulatoryinfopackagenotificationrecord", + index=models.Index( + fields=["recipient", "send_status"], name="idx_ra_rip_notify_recipient" + ), + ), + migrations.AddIndex( + model_name="regulatoryinfopackagenotificationrecord", + index=models.Index( + fields=["send_status", "retry_count"], name="idx_ra_rip_notify_status" + ), + ), + ] diff --git a/review_agent/migrations/__init__.py b/review_agent/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/review_agent/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/review_agent/models.py b/review_agent/models.py new file mode 100644 index 0000000..16da526 --- /dev/null +++ b/review_agent/models.py @@ -0,0 +1,1179 @@ +from django.conf import settings +from django.db import models + + +class Conversation(models.Model): + """Stores a user's review-agent conversation shell.""" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="review_conversations", + ) + title = models.CharField(max_length=120, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at", "-id"] + + def __str__(self) -> str: + return self.title or f"对话 {self.pk}" + + +class Message(models.Model): + """Stores one user or assistant message in a conversation.""" + + class Role(models.TextChoices): + USER = "user", "用户" + ASSISTANT = "assistant", "助手" + + conversation = models.ForeignKey( + Conversation, + on_delete=models.CASCADE, + related_name="messages", + ) + role = models.CharField(max_length=20, choices=Role.choices) + content = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["created_at", "id"] + + def __str__(self) -> str: + return f"{self.get_role_display()} - {self.conversation_id}" + + +class FileAttachment(models.Model): + """Stores an uploaded file version for one conversation.""" + + class UploadStatus(models.TextChoices): + UPLOADED = "uploaded", "已上传" + BOUND = "bound", "已绑定" + DELETED = "deleted", "已删除" + + conversation = models.ForeignKey( + Conversation, + on_delete=models.CASCADE, + related_name="file_attachments", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="review_file_attachments", + ) + original_name = models.CharField(max_length=255) + version_no = models.PositiveIntegerField(default=1) + is_active = models.BooleanField(default=True) + storage_path = models.CharField(max_length=500) + file_size = models.BigIntegerField(default=0) + content_type = models.CharField(max_length=120, blank=True, default="") + upload_status = models.CharField( + max_length=20, + choices=UploadStatus.choices, + default=UploadStatus.UPLOADED, + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "ra_file_attachment" + ordering = ["-created_at", "-id"] + constraints = [ + models.UniqueConstraint( + fields=["conversation", "original_name", "version_no"], + name="uq_ra_attachment_conv_name_version", + ) + ] + indexes = [ + models.Index( + fields=["conversation", "created_at"], + name="idx_ra_attachment_conv_created", + ), + models.Index( + fields=["user", "created_at"], + name="idx_ra_attachment_user_created", + ), + models.Index( + fields=["conversation", "original_name", "is_active"], + name="idx_ra_attachment_active", + ), + ] + + def __str__(self) -> str: + return f"{self.original_name} v{self.version_no}" + + +class FileSummaryBatch(models.Model): + """Tracks one automatic file inventory and page-count workflow run.""" + + class Status(models.TextChoices): + PENDING = "pending", "待执行" + RUNNING = "running", "执行中" + SUCCESS = "success", "成功" + FAILED = "failed", "失败" + + conversation = models.ForeignKey( + Conversation, + on_delete=models.CASCADE, + related_name="file_summary_batches", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="review_file_summary_batches", + ) + trigger_message = models.ForeignKey( + Message, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="triggered_file_summary_batches", + ) + batch_no = models.CharField(max_length=64, unique=True) + product_name = models.CharField(max_length=200, blank=True, default="") + status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) + total_files = models.IntegerField(default=0) + supported_files = models.IntegerField(default=0) + success_files = models.IntegerField(default=0) + failed_files = models.IntegerField(default=0) + unsupported_files = models.IntegerField(default=0) + uncertain_files = models.IntegerField(default=0) + total_pages = models.IntegerField(default=0) + work_dir = models.CharField(max_length=500, blank=True, default="") + error_message = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "ra_file_summary_batch" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["conversation", "created_at"], name="idx_ra_batch_conv_created"), + models.Index(fields=["user", "created_at"], name="idx_ra_batch_user_created"), + models.Index(fields=["status", "created_at"], name="idx_ra_batch_status"), + ] + + def __str__(self) -> str: + return self.batch_no + + +class FileSummaryBatchAttachment(models.Model): + """Binds a workflow batch to the exact attachment versions it uses.""" + + class SourceRole(models.TextChoices): + ARCHIVE = "archive", "压缩包" + MULTI_FILE = "multi_file", "多文件" + + batch = models.ForeignKey( + FileSummaryBatch, + on_delete=models.CASCADE, + related_name="batch_attachments", + ) + attachment = models.ForeignKey( + FileAttachment, + on_delete=models.CASCADE, + related_name="batch_bindings", + ) + source_role = models.CharField( + max_length=20, + choices=SourceRole.choices, + default=SourceRole.MULTI_FILE, + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "ra_file_summary_batch_attachment" + constraints = [ + models.UniqueConstraint( + fields=["batch", "attachment"], + name="uq_ra_batch_attachment", + ) + ] + indexes = [ + models.Index( + fields=["batch", "created_at"], + name="idx_ra_batch_attachment_batch", + ), + models.Index(fields=["attachment"], name="idx_ra_batch_attach_file"), + ] + + +class FileSummaryItem(models.Model): + """Stores one scanned file and its page-count result.""" + + class StatisticsStatus(models.TextChoices): + SUCCESS = "success", "成功" + FAILED = "failed", "失败" + UNSUPPORTED = "unsupported", "不支持" + UNCERTAIN = "uncertain", "不确定" + SKIPPED = "skipped", "跳过" + + batch = models.ForeignKey( + FileSummaryBatch, + on_delete=models.CASCADE, + related_name="items", + ) + file_index = models.PositiveIntegerField() + directory_level = models.CharField(max_length=300, blank=True, default="") + file_name = models.CharField(max_length=255) + file_type = models.CharField(max_length=20) + relative_path = models.CharField(max_length=500) + storage_path = models.CharField(max_length=500) + page_count = models.IntegerField(null=True, blank=True) + statistics_status = models.CharField( + max_length=20, + choices=StatisticsStatus.choices, + default=StatisticsStatus.SKIPPED, + ) + retry_count = models.PositiveIntegerField(default=0) + error_message = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ra_file_summary_item" + ordering = ["file_index", "id"] + constraints = [ + models.UniqueConstraint( + fields=["batch", "relative_path"], + name="uq_ra_item_batch_relative_path", + ) + ] + indexes = [ + models.Index(fields=["batch", "file_index"], name="idx_ra_item_batch_index"), + models.Index(fields=["batch", "statistics_status"], name="idx_ra_item_batch_status"), + models.Index(fields=["batch", "file_type"], name="idx_ra_item_batch_type"), + ] + + +class WorkflowNodeRun(models.Model): + """Stores recoverable status for one workflow node.""" + + class Status(models.TextChoices): + PENDING = "pending", "等待中" + RUNNING = "running", "执行中" + WAITING_USER = "waiting_user", "等待用户确认" + RETRYING = "retrying", "重试中" + SUCCESS = "success", "成功" + FAILED = "failed", "失败" + SKIPPED = "skipped", "跳过" + + batch = models.ForeignKey( + FileSummaryBatch, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="node_runs", + ) + workflow_type = models.CharField(max_length=40, blank=True, default="file_summary") + workflow_batch_id = models.PositiveBigIntegerField(null=True, blank=True) + node_group = models.CharField(max_length=40, blank=True, default="file_summary") + node_code = models.CharField(max_length=40) + node_name = models.CharField(max_length=80) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) + progress = models.PositiveIntegerField(default=0) + message = models.TextField(blank=True, default="") + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "ra_workflow_node_run" + constraints = [ + models.UniqueConstraint(fields=["batch", "node_code"], name="uq_ra_node_batch_code"), + models.UniqueConstraint( + fields=["workflow_type", "workflow_batch_id", "node_code"], + name="uq_ra_node_workflow_batch_code", + ), + ] + indexes = [ + models.Index(fields=["batch", "status"], name="idx_ra_node_batch_status"), + models.Index( + fields=["workflow_type", "workflow_batch_id"], + name="idx_ra_node_workflow", + ), + ] + + +class WorkflowEvent(models.Model): + """Persists workflow events for SSE replay and diagnostics.""" + + batch = models.ForeignKey( + FileSummaryBatch, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="events", + ) + workflow_type = models.CharField(max_length=40, blank=True, default="file_summary") + workflow_batch_id = models.PositiveBigIntegerField(null=True, blank=True) + conversation = models.ForeignKey( + Conversation, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="workflow_events", + ) + event_type = models.CharField(max_length=40) + payload = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "ra_workflow_event" + ordering = ["id"] + indexes = [ + models.Index(fields=["batch", "id"], name="idx_ra_event_batch_id"), + models.Index(fields=["batch", "created_at"], name="idx_ra_event_batch_created"), + models.Index( + fields=["workflow_type", "workflow_batch_id", "id"], + name="idx_ra_event_workflow_id", + ), + ] + + +class ExportedSummaryFile(models.Model): + """Stores generated report files for permission-checked download.""" + + class ExportType(models.TextChoices): + MARKDOWN = "markdown", "Markdown" + EXCEL = "excel", "Excel" + JSON = "json", "JSON" + WORD = "word", "Word" + PDF = "pdf", "PDF" + ZIP = "zip", "ZIP" + + class Status(models.TextChoices): + SUCCESS = "success", "成功" + FAILED = "failed", "失败" + + batch = models.ForeignKey( + FileSummaryBatch, + on_delete=models.CASCADE, + related_name="exports", + null=True, + blank=True, + ) + workflow_type = models.CharField(max_length=40, blank=True, default="file_summary") + workflow_batch_id = models.PositiveBigIntegerField(null=True, blank=True) + export_category = models.CharField(max_length=40, blank=True, default="summary") + export_type = models.CharField(max_length=20, choices=ExportType.choices) + file_name = models.CharField(max_length=255) + storage_path = models.CharField(max_length=500) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.SUCCESS) + error_message = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "ra_exported_summary_file" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["batch", "export_type"], name="idx_ra_export_batch_type"), + models.Index(fields=["batch", "created_at"], name="idx_ra_export_batch_created"), + models.Index( + fields=["workflow_type", "workflow_batch_id"], + name="idx_ra_export_workflow", + ), + ] + + +class RegulatoryRuleVersion(models.Model): + """Tracks the local regulatory rule YAML and its matching RAG index.""" + + class Status(models.TextChoices): + ACTIVE = "active", "启用" + OUTDATED = "outdated", "待更新" + DISABLED = "disabled", "停用" + + code = models.CharField(max_length=80, unique=True) + name = models.CharField(max_length=160) + yaml_path = models.CharField(max_length=500) + yaml_hash = models.CharField(max_length=128) + rag_collection = models.CharField(max_length=120, blank=True, default="") + rag_index_version = models.CharField(max_length=80, blank=True, default="") + rag_index_hash = models.CharField(max_length=128, blank=True, default="") + status = models.CharField(max_length=20, choices=Status.choices, default=Status.ACTIVE) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ra_regulatory_rule_version" + ordering = ["-updated_at", "-id"] + indexes = [ + models.Index(fields=["code", "status"], name="idx_ra_rule_code_status"), + ] + + def __str__(self) -> str: + return self.code + + +class KnowledgeBaseDocument(models.Model): + """Stores user-managed knowledge-base source documents.""" + + class Status(models.TextChoices): + ACTIVE = "active", "启用" + DISABLED = "disabled", "停用" + DELETED = "deleted", "已删除" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="knowledge_base_documents", + ) + display_name = models.CharField(max_length=255) + original_name = models.CharField(max_length=255) + storage_path = models.CharField(max_length=500) + file_size = models.BigIntegerField(default=0) + content_type = models.CharField(max_length=120, blank=True, default="") + description = models.TextField(blank=True, default="") + status = models.CharField(max_length=20, choices=Status.choices, default=Status.ACTIVE) + is_active = models.BooleanField(default=True) + indexed_chunk_count = models.PositiveIntegerField(default=0) + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ra_knowledge_base_document" + ordering = ["-updated_at", "-id"] + indexes = [ + models.Index(fields=["user", "status"], name="idx_ra_kb_doc_user_status"), + models.Index(fields=["user", "created_at"], name="idx_ra_kb_doc_user_created"), + models.Index(fields=["status", "updated_at"], name="idx_ra_kb_doc_status_updated"), + ] + + def __str__(self) -> str: + return self.display_name + + +class ApplicationFormFillBatch(models.Model): + """Tracks one application-form auto-fill workflow run.""" + + class Status(models.TextChoices): + PENDING = "pending", "待执行" + RUNNING = "running", "执行中" + WAITING_USER = "waiting_user", "等待用户" + SUCCESS = "success", "成功" + PARTIAL_SUCCESS = "partial_success", "部分成功" + FAILED = "failed", "失败" + CANCELLED = "cancelled", "已取消" + + class RegistrationTypeSource(models.TextChoices): + USER_MESSAGE = "user_message", "用户话语" + REGULATORY_BATCH = "regulatory_batch", "法规核查批次" + FILE_EXTRACT = "file_extract", "文件抽取" + UNKNOWN = "unknown", "未知" + + conversation = models.ForeignKey( + Conversation, + on_delete=models.CASCADE, + related_name="application_form_fill_batches", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="review_application_form_fill_batches", + ) + trigger_message = models.ForeignKey( + Message, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="triggered_application_form_fill_batches", + ) + source_summary_batch = models.ForeignKey( + FileSummaryBatch, + on_delete=models.PROTECT, + related_name="application_form_fill_batches", + ) + source_regulatory_batch = models.ForeignKey( + "RegulatoryReviewBatch", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="application_form_fill_batches", + ) + batch_no = models.CharField(max_length=64, unique=True) + status = models.CharField(max_length=30, choices=Status.choices, default=Status.PENDING) + requested_templates = models.JSONField(default=list, blank=True) + selected_templates = models.JSONField(default=list, blank=True) + output_types = models.JSONField(default=list, blank=True) + registration_type = models.CharField(max_length=80, blank=True, default="") + registration_type_source = models.CharField( + max_length=40, + choices=RegistrationTypeSource.choices, + default=RegistrationTypeSource.UNKNOWN, + ) + product_name = models.CharField(max_length=200, blank=True, default="") + conflict_summary = models.JSONField(default=list, blank=True) + risk_notes = models.JSONField(default=list, blank=True) + template_config_version = models.CharField(max_length=80, blank=True, default="") + template_config_hash = models.CharField(max_length=128, blank=True, default="") + work_dir = models.CharField(max_length=500, blank=True, default="") + error_message = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + archived_at = models.DateTimeField(null=True, blank=True) + is_deleted = models.BooleanField(default=False) + + class Meta: + db_table = "ra_application_form_fill_batch" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["conversation", "status"], name="idx_ra_aff_batch_conv_status"), + models.Index(fields=["source_summary_batch"], name="idx_ra_aff_batch_summary"), + models.Index(fields=["source_regulatory_batch"], name="idx_ra_aff_batch_regulatory"), + models.Index(fields=["user", "created_at"], name="idx_ra_aff_batch_user_created"), + models.Index(fields=["created_at"], name="idx_ra_aff_batch_created"), + ] + + def __str__(self) -> str: + return self.batch_no + + +class RegulatoryInfoPackageBatch(models.Model): + """Tracks one Chapter 1 regulatory information package workflow run.""" + + class Status(models.TextChoices): + PENDING = "pending", "待执行" + RUNNING = "running", "执行中" + WAITING_USER = "waiting_user", "等待用户" + SUCCESS = "success", "成功" + PARTIAL_SUCCESS = "partial_success", "部分成功" + FAILED = "failed", "失败" + CANCELLED = "cancelled", "已取消" + + conversation = models.ForeignKey( + Conversation, + on_delete=models.CASCADE, + related_name="regulatory_info_package_batches", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="review_regulatory_info_package_batches", + ) + trigger_message = models.ForeignKey( + Message, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="triggered_regulatory_info_package_batches", + ) + source_attachment = models.ForeignKey( + FileAttachment, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="regulatory_info_package_batches", + ) + source_summary_batch = models.ForeignKey( + FileSummaryBatch, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="regulatory_info_package_batches", + ) + source_summary_item_id = models.PositiveBigIntegerField(null=True, blank=True) + batch_no = models.CharField(max_length=64, unique=True) + status = models.CharField(max_length=30, choices=Status.choices, default=Status.PENDING) + source_file_name = models.CharField(max_length=255, blank=True, default="") + source_storage_path = models.CharField(max_length=500, blank=True, default="") + product_name = models.CharField(max_length=200, blank=True, default="") + output_zip_name = models.CharField(max_length=255, blank=True, default="第1章 监管信息(预生成版).zip") + generated_files = models.JSONField(default=list, blank=True) + missing_fields = models.JSONField(default=list, blank=True) + llm_only_fields = models.JSONField(default=list, blank=True) + conflict_fields = models.JSONField(default=list, blank=True) + risk_notes = models.JSONField(default=list, blank=True) + template_config_version = models.CharField(max_length=80, blank=True, default="") + template_config_hash = models.CharField(max_length=128, blank=True, default="") + adapter_summary = models.JSONField(default=dict, blank=True) + work_dir = models.CharField(max_length=500, blank=True, default="") + error_message = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + archived_at = models.DateTimeField(null=True, blank=True) + is_deleted = models.BooleanField(default=False) + + class Meta: + db_table = "ra_regulatory_info_package_batch" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["conversation", "status"], name="idx_ra_rip_batch_conv_status"), + models.Index(fields=["user", "created_at"], name="idx_ra_rip_batch_user_created"), + models.Index(fields=["source_attachment"], name="idx_ra_rip_batch_attachment"), + models.Index(fields=["source_summary_batch"], name="idx_ra_rip_batch_summary"), + models.Index(fields=["created_at"], name="idx_ra_rip_batch_created"), + ] + + def __str__(self) -> str: + return self.batch_no + + +class RegulatoryReviewBatch(models.Model): + """Tracks one NMPA regulatory review workflow run.""" + + class Status(models.TextChoices): + PENDING = "pending", "待执行" + RUNNING = "running", "执行中" + WAITING_USER = "waiting_user", "等待用户确认" + SUCCESS = "success", "成功" + FAILED = "failed", "失败" + + conversation = models.ForeignKey( + Conversation, + on_delete=models.CASCADE, + related_name="regulatory_review_batches", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="review_regulatory_batches", + ) + trigger_message = models.ForeignKey( + Message, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="triggered_regulatory_batches", + ) + source_summary_batch = models.ForeignKey( + FileSummaryBatch, + on_delete=models.PROTECT, + related_name="regulatory_review_batches", + ) + rule_version = models.ForeignKey( + RegulatoryRuleVersion, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="review_batches", + ) + batch_no = models.CharField(max_length=64, unique=True) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) + condition_json = models.JSONField(default=dict, blank=True) + risk_summary = models.JSONField(default=dict, blank=True) + work_dir = models.CharField(max_length=500, blank=True, default="") + error_message = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "ra_regulatory_review_batch" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["conversation", "created_at"], name="idx_ra_rr_batch_conv"), + models.Index(fields=["user", "created_at"], name="idx_ra_rr_batch_user"), + models.Index(fields=["status", "created_at"], name="idx_ra_rr_batch_status"), + ] + + def __str__(self) -> str: + return self.batch_no + + +class RegulatoryIssue(models.Model): + """Stores one regulatory finding after risk consolidation.""" + + class Severity(models.TextChoices): + BLOCKING = "blocking", "阻断项" + HIGH = "high", "高风险" + MEDIUM = "medium", "中风险" + LOW = "low", "低风险" + INFO = "info", "提示" + + class Category(models.TextChoices): + COMPLETENESS = "completeness", "完整性" + STRUCTURE = "structure", "章节" + CONSISTENCY = "consistency", "一致性" + RAG = "rag", "法规依据" + + class Status(models.TextChoices): + OPEN = "open", "待处理" + RESOLVED = "resolved", "已整改" + ACCEPTED = "accepted", "已接受" + REVIEW_PASSED = "review_passed", "复核通过" + REVIEW_FAILED = "review_failed", "复核未通过" + + batch = models.ForeignKey( + RegulatoryReviewBatch, + on_delete=models.CASCADE, + related_name="issues", + ) + rule_code = models.CharField(max_length=120, blank=True, default="") + category = models.CharField(max_length=40, choices=Category.choices) + severity = models.CharField(max_length=20, choices=Severity.choices) + title = models.CharField(max_length=255) + detail = models.TextField(blank=True, default="") + suggestion = models.TextField(blank=True, default="") + status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN) + evidence = models.JSONField(default=dict, blank=True) + citations = models.JSONField(default=list, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ra_regulatory_issue" + ordering = ["severity", "id"] + indexes = [ + models.Index(fields=["batch", "severity"], name="idx_ra_rr_issue_severity"), + models.Index(fields=["batch", "category"], name="idx_ra_rr_issue_category"), + ] + + def __str__(self) -> str: + return self.title + + +class RegulatoryArtifact(models.Model): + """Stores regulatory review intermediate and exported artifacts.""" + + class ArtifactType(models.TextChoices): + MARKDOWN = "markdown", "Markdown" + EXCEL = "excel", "Excel" + JSON = "json", "JSON" + TEXT = "text", "文本" + + batch = models.ForeignKey( + RegulatoryReviewBatch, + on_delete=models.CASCADE, + related_name="artifacts", + ) + artifact_type = models.CharField(max_length=20, choices=ArtifactType.choices) + name = models.CharField(max_length=160) + storage_path = models.CharField(max_length=500) + content_hash = models.CharField(max_length=128, blank=True, default="") + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "ra_regulatory_artifact" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["batch", "artifact_type"], name="idx_ra_rr_artifact_type"), + ] + + +class RegulatoryNotificationRecord(models.Model): + """Stores mock notification records for future Feishu integration.""" + + class Channel(models.TextChoices): + MOCK = "mock", "模拟" + FEISHU = "feishu", "飞书" + + class Status(models.TextChoices): + PENDING = "pending", "待发送" + SENT = "sent", "已发送" + FAILED = "failed", "失败" + + batch = models.ForeignKey( + RegulatoryReviewBatch, + on_delete=models.CASCADE, + related_name="notifications", + ) + channel = models.CharField(max_length=20, choices=Channel.choices, default=Channel.MOCK) + target = models.CharField(max_length=160, blank=True, default="") + payload = models.JSONField(default=dict, blank=True) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) + error_message = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + sent_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "ra_regulatory_notification_record" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["batch", "status"], name="idx_ra_rr_notify_status"), + ] + + +class ApplicationFormFillArtifact(models.Model): + """Stores auto-fill intermediate files and generated artifacts.""" + + class ArtifactType(models.TextChoices): + TEMPLATE_COPY = "template_copy", "模板副本" + FIELD_EXTRACT_RESULT = "field_extract_result", "字段抽取结果" + MERGED_FIELDS = "merged_fields", "字段合并结果" + TRACEABILITY = "traceability", "追溯清单" + FILLED_TEMPLATE = "filled_template", "已填模板" + NOTIFICATION_RECORD = "notification_record", "通知记录" + + class FileFormat(models.TextChoices): + JSON = "json", "JSON" + EXCEL = "excel", "Excel" + DOCX = "docx", "DOCX" + PDF = "pdf", "PDF" + MARKDOWN = "markdown", "Markdown" + + batch = models.ForeignKey( + ApplicationFormFillBatch, + on_delete=models.CASCADE, + related_name="artifacts", + ) + artifact_type = models.CharField(max_length=60, choices=ArtifactType.choices) + file_format = models.CharField(max_length=20, choices=FileFormat.choices) + name = models.CharField(max_length=160) + file_name = models.CharField(max_length=255) + storage_path = models.CharField(max_length=500) + file_size = models.BigIntegerField(default=0) + content_hash = models.CharField(max_length=128, blank=True, default="") + metadata = models.JSONField(default=dict, blank=True) + created_by_node = models.CharField(max_length=60, blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + is_deleted = models.BooleanField(default=False) + + class Meta: + db_table = "ra_application_form_fill_artifact" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["batch", "artifact_type"], name="idx_ra_aff_artifact_batch_type"), + models.Index(fields=["file_format"], name="idx_ra_aff_artifact_format"), + models.Index(fields=["created_at"], name="idx_ra_aff_artifact_created"), + ] + + +class RegulatoryInfoPackageArtifact(models.Model): + """Stores regulatory information package intermediate and generated files.""" + + class ArtifactType(models.TextChoices): + TEMPLATE_COPY = "template_copy", "模板副本" + INSTRUCTION_EXTRACT = "instruction_extract", "说明书抽取结果" + FIELD_EXTRACT_RESULT = "field_extract_result", "字段抽取结果" + MERGED_FIELDS = "merged_fields", "合并字段" + GENERATED_DOCUMENT = "generated_document", "生成文件" + TRACEABILITY = "traceability", "追溯清单" + ZIP_PACKAGE = "zip_package", "ZIP包" + NOTIFICATION_RECORD = "notification_record", "通知记录" + + class FileFormat(models.TextChoices): + JSON = "json", "JSON" + EXCEL = "excel", "Excel" + DOCX = "docx", "DOCX" + DOC = "doc", "DOC" + ZIP = "zip", "ZIP" + MARKDOWN = "markdown", "Markdown" + + batch = models.ForeignKey( + RegulatoryInfoPackageBatch, + on_delete=models.CASCADE, + related_name="artifacts", + ) + artifact_type = models.CharField(max_length=60, choices=ArtifactType.choices) + file_format = models.CharField(max_length=20, choices=FileFormat.choices) + name = models.CharField(max_length=160) + file_name = models.CharField(max_length=255) + storage_path = models.CharField(max_length=500) + file_size = models.BigIntegerField(default=0) + content_hash = models.CharField(max_length=128, blank=True, default="") + metadata = models.JSONField(default=dict, blank=True) + created_by_node = models.CharField(max_length=60, blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + is_deleted = models.BooleanField(default=False) + + class Meta: + db_table = "ra_regulatory_info_package_artifact" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["batch", "artifact_type"], name="idx_ra_rip_artifact_batch_type"), + models.Index(fields=["file_format"], name="idx_ra_rip_artifact_format"), + models.Index(fields=["created_at"], name="idx_ra_rip_artifact_created"), + ] + + +class ApplicationFormFillNotificationRecord(models.Model): + """Stores mock/Feishu notification records for application-form auto-fill.""" + + class Channel(models.TextChoices): + FEISHU_CLI = "feishu_cli", "飞书 CLI" + FEISHU_API = "feishu_api", "飞书 API" + MOCK = "mock", "模拟" + + class SendStatus(models.TextChoices): + PENDING = "pending", "待发送" + SUCCESS = "success", "成功" + FAILED = "failed", "失败" + + batch = models.ForeignKey( + ApplicationFormFillBatch, + on_delete=models.CASCADE, + related_name="notifications", + ) + recipient = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="application_form_fill_notifications", + ) + channel = models.CharField(max_length=30, choices=Channel.choices, default=Channel.MOCK) + template_codes = models.JSONField(default=list, blank=True) + export_ids = models.JSONField(default=list, blank=True) + message_summary = models.TextField(blank=True, default="") + send_status = models.CharField( + max_length=20, + choices=SendStatus.choices, + default=SendStatus.PENDING, + ) + retry_count = models.PositiveIntegerField(default=0) + external_message_id = models.CharField(max_length=120, blank=True, default="") + error_message = models.TextField(blank=True, default="") + sent_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + is_deleted = models.BooleanField(default=False) + + class Meta: + db_table = "ra_application_form_fill_notification_record" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["batch", "created_at"], name="idx_ra_aff_notify_batch"), + models.Index(fields=["recipient", "send_status"], name="idx_ra_aff_notify_recipient"), + models.Index(fields=["send_status", "retry_count"], name="idx_ra_aff_notify_status"), + ] + + +class RegulatoryInfoPackageNotificationRecord(models.Model): + """Stores mock/Feishu notification records for regulatory info packages.""" + + class Channel(models.TextChoices): + FEISHU_CLI = "feishu_cli", "飞书 CLI" + FEISHU_API = "feishu_api", "飞书 API" + MOCK = "mock", "模拟" + + class SendStatus(models.TextChoices): + PENDING = "pending", "待发送" + SUCCESS = "success", "成功" + FAILED = "failed", "失败" + + batch = models.ForeignKey( + RegulatoryInfoPackageBatch, + on_delete=models.CASCADE, + related_name="notifications", + ) + recipient = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="regulatory_info_package_notifications", + ) + channel = models.CharField(max_length=30, choices=Channel.choices, default=Channel.MOCK) + export_ids = models.JSONField(default=list, blank=True) + message_summary = models.TextField(blank=True, default="") + send_status = models.CharField( + max_length=20, + choices=SendStatus.choices, + default=SendStatus.PENDING, + ) + retry_count = models.PositiveIntegerField(default=0) + external_message_id = models.CharField(max_length=120, blank=True, default="") + error_message = models.TextField(blank=True, default="") + sent_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + is_deleted = models.BooleanField(default=False) + + class Meta: + db_table = "ra_regulatory_info_package_notification_record" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["batch", "created_at"], name="idx_ra_rip_notify_batch"), + models.Index(fields=["recipient", "send_status"], name="idx_ra_rip_notify_recipient"), + models.Index(fields=["send_status", "retry_count"], name="idx_ra_rip_notify_status"), + ] + + +class FeishuUserMapping(models.Model): + """Maps a system user to Feishu identifiers maintained by Admin.""" + + system_user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="feishu_mapping", + ) + feishu_display_name = models.CharField(max_length=120, blank=True, default="") + feishu_open_id = models.CharField(max_length=120, blank=True, default="") + feishu_user_id = models.CharField(max_length=120, blank=True, default="") + feishu_mobile = models.CharField(max_length=40, blank=True, default="") + is_active = models.BooleanField(default=True) + remark = models.CharField(max_length=255, blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ra_feishu_user_mapping" + ordering = ["system_user__username", "id"] + indexes = [ + models.Index(fields=["is_active"], name="idx_ra_feishu_map_active"), + models.Index(fields=["feishu_open_id"], name="idx_ra_feishu_map_open"), + models.Index(fields=["feishu_user_id"], name="idx_ra_feishu_map_userid"), + models.Index(fields=["feishu_mobile"], name="idx_ra_feishu_map_mobile"), + ] + + def preferred_identifier(self) -> tuple[str, str]: + if self.feishu_open_id: + return "open_id", self.feishu_open_id + if self.feishu_user_id: + return "user_id", self.feishu_user_id + if self.feishu_mobile: + return "mobile", self.feishu_mobile + return "missing", "" + + def __str__(self) -> str: + return self.feishu_display_name or self.system_user.get_username() + + +class FeishuAccessTokenCache(models.Model): + """Caches Feishu tenant_access_token until its expiry time.""" + + app_id_hash = models.CharField(max_length=128, unique=True) + tenant_access_token = models.TextField(blank=True, default="") + expires_at = models.DateTimeField(null=True, blank=True) + error_message = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ra_feishu_access_token_cache" + ordering = ["-updated_at", "-id"] + indexes = [ + models.Index(fields=["app_id_hash"], name="idx_ra_feishu_token_app"), + models.Index(fields=["expires_at"], name="idx_ra_feishu_token_exp"), + ] + + def is_valid(self, now=None) -> bool: + from django.utils import timezone + + current = now or timezone.now() + return bool(self.tenant_access_token and self.expires_at and self.expires_at > current) + + def __str__(self) -> str: + return f"Feishu token cache {self.app_id_hash[:8]}" + + +class WorkflowNotificationRecord(models.Model): + """Stores unified notification send records for all workflow types.""" + + class Channel(models.TextChoices): + MOCK = "mock", "模拟" + DISABLED = "disabled", "未启用" + FEISHU_API = "feishu_api", "飞书 API" + + class SendStatus(models.TextChoices): + PENDING = "pending", "待发送" + SUCCESS = "success", "成功" + FAILED = "failed", "失败" + SKIPPED_DUPLICATE = "skipped_duplicate", "重复跳过" + DISABLED = "disabled", "未启用" + + workflow_type = models.CharField(max_length=40) + workflow_batch_id = models.PositiveBigIntegerField() + workflow_batch_no = models.CharField(max_length=80) + workflow_status = models.CharField(max_length=40) + dedupe_key = models.CharField(max_length=160) + trigger_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="workflow_notification_records", + ) + feishu_mapping = models.ForeignKey( + FeishuUserMapping, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="notification_records", + ) + channel = models.CharField(max_length=40, choices=Channel.choices, default=Channel.MOCK) + target = models.CharField(max_length=160, blank=True, default="") + at_display_name = models.CharField(max_length=120, blank=True, default="") + at_identifier_type = models.CharField(max_length=30, blank=True, default="") + at_identifier_masked = models.CharField(max_length=120, blank=True, default="") + send_status = models.CharField( + max_length=30, + choices=SendStatus.choices, + default=SendStatus.PENDING, + ) + message_title = models.CharField(max_length=200) + message_summary = models.TextField(blank=True, default="") + result_url = models.CharField(max_length=500, blank=True, default="") + external_message_id = models.CharField(max_length=120, blank=True, default="") + error_code = models.CharField(max_length=80, blank=True, default="") + error_message = models.TextField(blank=True, default="") + request_duration_ms = models.PositiveIntegerField(null=True, blank=True) + sent_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ra_workflow_notification_record" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["workflow_type", "workflow_batch_id"], name="idx_ra_notify_workflow"), + models.Index(fields=["trigger_user", "created_at"], name="idx_ra_notify_user_created"), + models.Index(fields=["send_status", "created_at"], name="idx_ra_notify_status"), + models.Index(fields=["workflow_batch_no"], name="idx_ra_notify_batch_no"), + models.Index(fields=["dedupe_key", "send_status"], name="idx_ra_notify_dedupe_status"), + ] + + @classmethod + def build_dedupe_key(cls, workflow_type: str, workflow_batch_id: int, workflow_status: str) -> str: + return f"{workflow_type}:{workflow_batch_id}:{workflow_status}" + + @classmethod + def already_successfully_sent(cls, dedupe_key: str) -> bool: + return cls.objects.filter(dedupe_key=dedupe_key, send_status=cls.SendStatus.SUCCESS).exists() + + def __str__(self) -> str: + return f"{self.workflow_type} {self.workflow_batch_no} {self.send_status}" + + +class FeishuQuestionLog(models.Model): + """Records reserved Feishu question handling without storing full answers.""" + + class SourceType(models.TextChoices): + PRIVATE_CHAT = "private_chat", "私聊" + GROUP_MENTION = "group_mention", "群聊 @" + SIMULATE = "simulate", "本地模拟" + + class Status(models.TextChoices): + SUCCESS = "success", "成功" + FAILED = "failed", "失败" + IGNORED = "ignored", "忽略" + + system_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="feishu_question_logs", + ) + feishu_mapping = models.ForeignKey( + FeishuUserMapping, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="question_logs", + ) + feishu_open_id = models.CharField(max_length=120, blank=True, default="") + feishu_user_id = models.CharField(max_length=120, blank=True, default="") + source_type = models.CharField(max_length=30, choices=SourceType.choices, default=SourceType.SIMULATE) + message_id = models.CharField(max_length=120, blank=True, default="") + question_text = models.TextField() + intent = models.CharField(max_length=60, blank=True, default="") + query_object = models.JSONField(default=dict, blank=True) + answer_summary = models.TextField(blank=True, default="") + permission_result = models.CharField(max_length=40, blank=True, default="") + status = models.CharField(max_length=30, choices=Status.choices, default=Status.SUCCESS) + error_message = models.TextField(blank=True, default="") + processed_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "ra_feishu_question_log" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["system_user", "created_at"], name="idx_ra_feishu_q_user_created"), + models.Index(fields=["intent", "created_at"], name="idx_ra_feishu_q_intent"), + models.Index(fields=["status", "created_at"], name="idx_ra_feishu_q_status"), + models.Index(fields=["message_id"], name="idx_ra_feishu_q_message"), + ] + + def __str__(self) -> str: + return f"{self.intent or 'unknown'} {self.status}" diff --git a/review_agent/notifications/__init__.py b/review_agent/notifications/__init__.py new file mode 100644 index 0000000..7e468bc --- /dev/null +++ b/review_agent/notifications/__init__.py @@ -0,0 +1 @@ +"""Unified workflow notification services.""" diff --git a/review_agent/notifications/context.py b/review_agent/notifications/context.py new file mode 100644 index 0000000..fe10432 --- /dev/null +++ b/review_agent/notifications/context.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class NotificationContext: + workflow_type: str + workflow_name: str + workflow_batch_id: int + workflow_batch_no: str + workflow_status: str + trigger_user_id: int + trigger_username: str + title: str + summary_lines: tuple[str, ...] + next_step: str + result_path: str + + @property + def dedupe_key(self) -> str: + return f"{self.workflow_type}:{self.workflow_batch_id}:{self.workflow_status}" diff --git a/review_agent/notifications/dispatcher.py b/review_agent/notifications/dispatcher.py new file mode 100644 index 0000000..0bc3480 --- /dev/null +++ b/review_agent/notifications/dispatcher.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import logging + +from django.conf import settings + +from review_agent.models import WorkflowNotificationRecord + +from .context import NotificationContext +from .feishu_message_api import send_personal_message +from .feishu_token import get_tenant_access_token +from .message_builder import build_feishu_post_message, build_message_summary +from .recipient import ResolvedFeishuTarget, resolve_configured_personal_recipient +from .records import ( + create_disabled_record, + create_failed_record, + create_success_record, + existing_success_record, +) + + +logger = logging.getLogger("review_agent.notifications.dispatcher") + + +def dispatch_workflow_notification(context: NotificationContext) -> WorkflowNotificationRecord: + existing = existing_success_record(context) + if existing: + return existing + + try: + target = resolve_configured_personal_recipient() + summary = build_message_summary(context, target) + + if not getattr(settings, "FEISHU_NOTIFY_ENABLED", False): + return create_disabled_record(context, target, summary) + + if not target.ok: + return create_failed_record( + context, + target, + summary, + error_code=target.error_code, + error_message=target.error_message, + ) + + token_result = get_tenant_access_token() + if not token_result.ok: + return create_failed_record( + context, + target, + summary, + error_code=token_result.error_code, + error_message=token_result.error_message, + ) + + payload = build_feishu_post_message(context, target) + send_result = send_personal_message( + tenant_access_token=token_result.tenant_access_token, + receive_id_type=target.identifier_type, + payload=payload, + ) + if send_result.ok: + return create_success_record( + context, + target, + summary, + external_message_id=send_result.external_message_id, + request_duration_ms=send_result.request_duration_ms, + ) + return create_failed_record( + context, + target, + summary, + error_code=send_result.error_code, + error_message=send_result.error_message, + request_duration_ms=send_result.request_duration_ms, + ) + except Exception as exc: + logger.exception("Feishu notification dispatch failed", extra={"dedupe_key": context.dedupe_key}) + fallback_target = ResolvedFeishuTarget( + ok=False, + identifier_type="missing", + identifier_value="", + display_name=getattr(settings, "FEISHU_DEFAULT_TARGET_NAME", "默认飞书接收人"), + masked_identifier="", + error_code="dispatch_exception", + error_message=str(exc), + ) + return create_failed_record( + context, + fallback_target, + "\n".join([context.title, *context.summary_lines]), + error_code="dispatch_exception", + error_message=str(exc), + ) diff --git a/review_agent/notifications/feishu_message_api.py b/review_agent/notifications/feishu_message_api.py new file mode 100644 index 0000000..bfa002c --- /dev/null +++ b/review_agent/notifications/feishu_message_api.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from dataclasses import dataclass +import time + +from django.conf import settings +import httpx + +from .feishu_token import get_tenant_access_token + + +@dataclass(frozen=True) +class FeishuMessageResult: + ok: bool + external_message_id: str = "" + error_code: str = "" + error_message: str = "" + request_duration_ms: int | None = None + refreshed_token: bool = False + + +def send_personal_message( + *, + tenant_access_token: str, + receive_id_type: str, + payload: dict, + retry_on_token_expired: bool = True, +) -> FeishuMessageResult: + start = time.monotonic() + try: + response = httpx.post( + getattr(settings, "FEISHU_MESSAGE_API_URL"), + params={"receive_id_type": receive_id_type}, + json=payload, + headers={"Authorization": f"Bearer {tenant_access_token}"}, + timeout=10, + ) + duration_ms = int((time.monotonic() - start) * 1000) + data = response.json() + except httpx.TimeoutException: + return FeishuMessageResult(ok=False, error_code="timeout", error_message="发送飞书消息超时") + except Exception as exc: + return FeishuMessageResult(ok=False, error_code="request_error", error_message=str(exc)) + + if response.status_code >= 400: + return FeishuMessageResult( + ok=False, + error_code=str(response.status_code), + error_message=response.text[:500], + request_duration_ms=duration_ms, + ) + + code = int(data.get("code") or 0) + if code == 0: + message_id = str((data.get("data") or {}).get("message_id") or "") + return FeishuMessageResult(ok=True, external_message_id=message_id, request_duration_ms=duration_ms) + + if retry_on_token_expired and code in {99991663, 99991664, 99991668, 99991669}: + token_result = get_tenant_access_token(force_refresh=True) + if token_result.ok: + retry_result = send_personal_message( + tenant_access_token=token_result.tenant_access_token, + receive_id_type=receive_id_type, + payload=payload, + retry_on_token_expired=False, + ) + return FeishuMessageResult( + ok=retry_result.ok, + external_message_id=retry_result.external_message_id, + error_code=retry_result.error_code, + error_message=retry_result.error_message, + request_duration_ms=retry_result.request_duration_ms, + refreshed_token=True, + ) + return FeishuMessageResult( + ok=False, + error_code=token_result.error_code, + error_message=token_result.error_message, + request_duration_ms=duration_ms, + ) + + return FeishuMessageResult( + ok=False, + error_code=str(code or "api_error"), + error_message=str(data.get("msg") or "飞书消息 API 失败"), + request_duration_ms=duration_ms, + ) diff --git a/review_agent/notifications/feishu_token.py b/review_agent/notifications/feishu_token.py new file mode 100644 index 0000000..97d4af4 --- /dev/null +++ b/review_agent/notifications/feishu_token.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from dataclasses import dataclass +import hashlib + +from django.conf import settings +from django.utils import timezone +import httpx + +from review_agent.models import FeishuAccessTokenCache + + +@dataclass(frozen=True) +class FeishuTokenResult: + ok: bool + tenant_access_token: str = "" + error_code: str = "" + error_message: str = "" + + +def app_id_hash(app_id: str) -> str: + return hashlib.sha256(app_id.encode("utf-8")).hexdigest() + + +def get_tenant_access_token(*, force_refresh: bool = False) -> FeishuTokenResult: + app_id = getattr(settings, "FEISHU_APP_ID", "") + app_secret = getattr(settings, "FEISHU_APP_SECRET", "") + if not app_id or not app_secret: + return FeishuTokenResult( + ok=False, + error_code="config_missing", + error_message="未配置 FEISHU_APP_ID 或 FEISHU_APP_SECRET", + ) + + hashed_app_id = app_id_hash(app_id) + now = timezone.now() + cache = FeishuAccessTokenCache.objects.filter(app_id_hash=hashed_app_id).first() + if cache and not force_refresh and cache.is_valid(now=now): + return FeishuTokenResult(ok=True, tenant_access_token=cache.tenant_access_token) + + try: + response = httpx.post( + getattr(settings, "FEISHU_TOKEN_API_URL"), + json={"app_id": app_id, "app_secret": app_secret}, + timeout=10, + ) + data = response.json() + except httpx.TimeoutException: + return _save_token_error(hashed_app_id, "timeout", "获取 tenant_access_token 超时") + except Exception as exc: + return _save_token_error(hashed_app_id, "request_error", str(exc)) + + if response.status_code >= 400: + return _save_token_error(hashed_app_id, str(response.status_code), response.text[:500]) + if int(data.get("code") or 0) != 0: + return _save_token_error(hashed_app_id, str(data.get("code") or "api_error"), str(data.get("msg") or "token API 失败")) + + token = str(data.get("tenant_access_token") or "") + expire_seconds = int(data.get("expire") or getattr(settings, "FEISHU_TENANT_TOKEN_CACHE_SECONDS", 6600)) + if not token: + return _save_token_error(hashed_app_id, "token_missing", "飞书未返回 tenant_access_token") + + FeishuAccessTokenCache.objects.update_or_create( + app_id_hash=hashed_app_id, + defaults={ + "tenant_access_token": token, + "expires_at": now + timezone.timedelta(seconds=max(expire_seconds - 60, 60)), + "error_message": "", + }, + ) + return FeishuTokenResult(ok=True, tenant_access_token=token) + + +def _save_token_error(app_id_hash_value: str, error_code: str, error_message: str) -> FeishuTokenResult: + FeishuAccessTokenCache.objects.update_or_create( + app_id_hash=app_id_hash_value, + defaults={ + "tenant_access_token": "", + "expires_at": None, + "error_message": error_message[:1000], + }, + ) + return FeishuTokenResult(ok=False, error_code=error_code, error_message=error_message[:1000]) diff --git a/review_agent/notifications/message_builder.py b/review_agent/notifications/message_builder.py new file mode 100644 index 0000000..9a2666f --- /dev/null +++ b/review_agent/notifications/message_builder.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json + +from django.conf import settings + +from .context import NotificationContext +from .recipient import ResolvedFeishuTarget + + +def absolute_result_url(path: str) -> str: + base_url = getattr(settings, "PUBLIC_BASE_URL", "http://127.0.0.1:8000").rstrip("/") + if not path: + return base_url + if path.startswith("http://") or path.startswith("https://"): + return path + return f"{base_url}/{path.lstrip('/')}" + + +def build_message_summary(context: NotificationContext, target: ResolvedFeishuTarget) -> str: + lines = [ + context.title, + f"批次:{context.workflow_batch_no}", + f"状态:{context.workflow_status}", + f"发起人:{context.trigger_username}", + f"接收人:{target.display_name}", + *context.summary_lines, + f"下一步:{context.next_step}", + ] + return "\n".join(line for line in lines if line) + + +def build_feishu_post_message(context: NotificationContext, target: ResolvedFeishuTarget) -> dict: + result_url = absolute_result_url(context.result_path) + content = [ + [{"tag": "text", "text": f"{context.title}\n"}], + [{"tag": "text", "text": f"流程:{context.workflow_name}\n"}], + [{"tag": "text", "text": f"批次:{context.workflow_batch_no}\n"}], + [{"tag": "text", "text": f"状态:{context.workflow_status}\n"}], + [{"tag": "text", "text": f"发起人:{context.trigger_username}\n"}], + ] + for line in context.summary_lines: + content.append([{"tag": "text", "text": f"{line}\n"}]) + content.extend( + [ + [{"tag": "text", "text": f"下一步:{context.next_step}\n"}], + [{"tag": "a", "text": "查看系统结果", "href": result_url}], + ] + ) + return { + "receive_id": target.identifier_value, + "msg_type": "post", + "content": json.dumps( + { + "zh_cn": { + "title": context.title, + "content": content, + } + }, + ensure_ascii=False, + ), + } diff --git a/review_agent/notifications/presenter.py b/review_agent/notifications/presenter.py new file mode 100644 index 0000000..92d31b6 --- /dev/null +++ b/review_agent/notifications/presenter.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from review_agent.models import WorkflowNotificationRecord + + +def get_notification_records(workflow_type: str, batch_id: int): + return WorkflowNotificationRecord.objects.filter( + workflow_type=workflow_type, + workflow_batch_id=batch_id, + ).order_by("-created_at", "-id") + + +def serialize_notification_record(record: WorkflowNotificationRecord) -> dict[str, object]: + return { + "id": record.pk, + "channel": record.channel, + "target": record.target, + "receiver": record.at_display_name or record.target, + "identifier_type": record.at_identifier_type, + "identifier_masked": record.at_identifier_masked, + "send_status": record.send_status, + "status_label": notification_status_label(record), + "sent_at": record.sent_at.isoformat() if record.sent_at else "", + "created_at": record.created_at.isoformat(), + "error_code": record.error_code, + "error_message": record.error_message, + } + + +def serialize_notification_records(workflow_type: str, batch_id: int) -> list[dict[str, object]]: + return [serialize_notification_record(record) for record in get_notification_records(workflow_type, batch_id)] + + +def notification_status_label(record: WorkflowNotificationRecord) -> str: + labels = { + WorkflowNotificationRecord.SendStatus.SUCCESS: "飞书通知已发送", + WorkflowNotificationRecord.SendStatus.FAILED: "飞书通知失败", + WorkflowNotificationRecord.SendStatus.DISABLED: "飞书通知未启用", + WorkflowNotificationRecord.SendStatus.SKIPPED_DUPLICATE: "飞书通知已跳过重复发送", + WorkflowNotificationRecord.SendStatus.PENDING: "飞书通知待发送", + } + return labels.get(record.send_status, record.send_status) diff --git a/review_agent/notifications/recipient.py b/review_agent/notifications/recipient.py new file mode 100644 index 0000000..b75ce2e --- /dev/null +++ b/review_agent/notifications/recipient.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from django.conf import settings + + +@dataclass(frozen=True) +class ResolvedFeishuTarget: + ok: bool + identifier_type: str + identifier_value: str + display_name: str + masked_identifier: str + error_code: str = "" + error_message: str = "" + + +def mask_identifier(value: str) -> str: + if not value: + return "" + if len(value) <= 8: + return value[:2] + "***" + return f"{value[:4]}***{value[-4:]}" + + +def resolve_configured_personal_recipient() -> ResolvedFeishuTarget: + open_id = getattr(settings, "FEISHU_DEFAULT_USER_OPEN_ID", "") + user_id = getattr(settings, "FEISHU_DEFAULT_USER_ID", "") + display_name = getattr(settings, "FEISHU_DEFAULT_TARGET_NAME", "默认飞书接收人") + if open_id: + return ResolvedFeishuTarget( + ok=True, + identifier_type="open_id", + identifier_value=open_id, + display_name=display_name, + masked_identifier=mask_identifier(open_id), + ) + if user_id: + return ResolvedFeishuTarget( + ok=True, + identifier_type="user_id", + identifier_value=user_id, + display_name=display_name, + masked_identifier=mask_identifier(user_id), + ) + return ResolvedFeishuTarget( + ok=False, + identifier_type="missing", + identifier_value="", + display_name=display_name, + masked_identifier="", + error_code="recipient_missing", + error_message="未配置 FEISHU_DEFAULT_USER_OPEN_ID 或 FEISHU_DEFAULT_USER_ID", + ) diff --git a/review_agent/notifications/records.py b/review_agent/notifications/records.py new file mode 100644 index 0000000..409f055 --- /dev/null +++ b/review_agent/notifications/records.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from django.utils import timezone + +from review_agent.models import WorkflowNotificationRecord + +from .context import NotificationContext +from .message_builder import absolute_result_url +from .recipient import ResolvedFeishuTarget + + +def existing_success_record(context: NotificationContext) -> WorkflowNotificationRecord | None: + return ( + WorkflowNotificationRecord.objects.filter( + dedupe_key=context.dedupe_key, + send_status=WorkflowNotificationRecord.SendStatus.SUCCESS, + ) + .order_by("-created_at", "-id") + .first() + ) + + +def create_disabled_record( + context: NotificationContext, + target: ResolvedFeishuTarget, + message_summary: str, +) -> WorkflowNotificationRecord: + return _create_record( + context, + target, + channel=WorkflowNotificationRecord.Channel.DISABLED, + send_status=WorkflowNotificationRecord.SendStatus.DISABLED, + message_summary=message_summary, + error_code="notify_disabled", + error_message="FEISHU_NOTIFY_ENABLED 未启用", + ) + + +def create_failed_record( + context: NotificationContext, + target: ResolvedFeishuTarget, + message_summary: str, + *, + error_code: str, + error_message: str, + request_duration_ms: int | None = None, +) -> WorkflowNotificationRecord: + return _create_record( + context, + target, + channel=WorkflowNotificationRecord.Channel.FEISHU_API, + send_status=WorkflowNotificationRecord.SendStatus.FAILED, + message_summary=message_summary, + error_code=error_code, + error_message=error_message, + request_duration_ms=request_duration_ms, + ) + + +def create_success_record( + context: NotificationContext, + target: ResolvedFeishuTarget, + message_summary: str, + *, + external_message_id: str, + request_duration_ms: int | None = None, +) -> WorkflowNotificationRecord: + return _create_record( + context, + target, + channel=WorkflowNotificationRecord.Channel.FEISHU_API, + send_status=WorkflowNotificationRecord.SendStatus.SUCCESS, + message_summary=message_summary, + external_message_id=external_message_id, + request_duration_ms=request_duration_ms, + sent_at=timezone.now(), + ) + + +def _create_record( + context: NotificationContext, + target: ResolvedFeishuTarget, + *, + channel: str, + send_status: str, + message_summary: str, + error_code: str = "", + error_message: str = "", + external_message_id: str = "", + request_duration_ms: int | None = None, + sent_at=None, +) -> WorkflowNotificationRecord: + return WorkflowNotificationRecord.objects.create( + workflow_type=context.workflow_type, + workflow_batch_id=context.workflow_batch_id, + workflow_batch_no=context.workflow_batch_no, + workflow_status=context.workflow_status, + dedupe_key=context.dedupe_key, + trigger_user_id=context.trigger_user_id, + channel=channel, + target=target.display_name, + at_display_name=target.display_name, + at_identifier_type=target.identifier_type, + at_identifier_masked=target.masked_identifier, + send_status=send_status, + message_title=context.title, + message_summary=message_summary, + result_url=absolute_result_url(context.result_path), + external_message_id=external_message_id, + error_code=error_code, + error_message=error_message[:1000], + request_duration_ms=request_duration_ms, + sent_at=sent_at, + ) diff --git a/review_agent/notifications/workflow_adapters.py b/review_agent/notifications/workflow_adapters.py new file mode 100644 index 0000000..d95f910 --- /dev/null +++ b/review_agent/notifications/workflow_adapters.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from review_agent.application_form_fill.constants import WORKFLOW_TYPE as FORM_FILL_WORKFLOW_TYPE +from review_agent.models import ( + ApplicationFormFillBatch, + ExportedSummaryFile, + FileSummaryBatch, + RegulatoryIssue, + RegulatoryReviewBatch, +) + +from .context import NotificationContext + + +def build_file_summary_context(batch: FileSummaryBatch) -> NotificationContext: + status = batch.status + abnormal_count = int(batch.failed_files or 0) + int(batch.unsupported_files or 0) + int(batch.uncertain_files or 0) + return NotificationContext( + workflow_type="file_summary", + workflow_name="自动汇总", + workflow_batch_id=batch.pk, + workflow_batch_no=batch.batch_no, + workflow_status=status, + trigger_user_id=batch.user_id, + trigger_username=batch.user.get_username(), + title=f"自动汇总{_status_label(status)}", + summary_lines=( + f"文件总数 {batch.total_files} 个,成功 {batch.success_files} 个", + f"异常/不支持/不确定 {abnormal_count} 个,总页数 {batch.total_pages}", + _error_line(batch.error_message), + ), + next_step="查看文件目录、页数统计和导出结果", + result_path=f"/api/review-agent/file-summary/{batch.pk}/status/", + ) + + +def build_regulatory_review_context(batch: RegulatoryReviewBatch) -> NotificationContext: + summary = batch.risk_summary or _count_regulatory_issues(batch) + return NotificationContext( + workflow_type="regulatory_review", + workflow_name="法规核查", + workflow_batch_id=batch.pk, + workflow_batch_no=batch.batch_no, + workflow_status=batch.status, + trigger_user_id=batch.user_id, + trigger_username=batch.user.get_username(), + title=f"法规核查{_status_label(batch.status)}", + summary_lines=( + f"阻断项 {int(summary.get('blocking') or 0)} 个,高风险 {int(summary.get('high') or 0)} 个", + f"中风险 {int(summary.get('medium') or 0)} 个,低风险 {int(summary.get('low') or 0)} 个", + _error_line(batch.error_message), + ), + next_step="查看风险报告并处理整改项", + result_path=f"/api/review-agent/regulatory-review/{batch.pk}/status/", + ) + + +def build_application_form_fill_context(batch: ApplicationFormFillBatch) -> NotificationContext: + export_count = ExportedSummaryFile.objects.filter( + workflow_type=FORM_FILL_WORKFLOW_TYPE, + workflow_batch_id=batch.pk, + ).count() + return NotificationContext( + workflow_type=FORM_FILL_WORKFLOW_TYPE, + workflow_name="自动填表", + workflow_batch_id=batch.pk, + workflow_batch_no=batch.batch_no, + workflow_status=batch.status, + trigger_user_id=batch.user_id, + trigger_username=batch.user.get_username(), + title=f"自动填表{_status_label(batch.status)}", + summary_lines=( + f"模板 {', '.join(batch.selected_templates or []) or '未识别'}", + f"导出文件 {export_count} 个,冲突字段 {len(batch.conflict_summary or [])} 个", + _error_line(batch.error_message), + ), + next_step="下载生成文件并检查字段冲突", + result_path=f"/api/review-agent/application-form-fill/{batch.pk}/status/", + ) + + +def _count_regulatory_issues(batch: RegulatoryReviewBatch) -> dict[str, int]: + return { + severity: RegulatoryIssue.objects.filter(batch=batch, severity=severity).count() + for severity in ["blocking", "high", "medium", "low", "info"] + } + + +def _status_label(status: str) -> str: + labels = { + "success": "完成", + "partial_success": "部分完成", + "failed": "失败", + "cancelled": "已取消", + } + return labels.get(status, status) + + +def _error_line(error_message: str) -> str: + if not error_message: + return "" + return f"失败原因:{error_message[:160]}" diff --git a/review_agent/regulatory_info_package/__init__.py b/review_agent/regulatory_info_package/__init__.py new file mode 100644 index 0000000..3026f19 --- /dev/null +++ b/review_agent/regulatory_info_package/__init__.py @@ -0,0 +1,2 @@ +"""Chapter 1 regulatory information package workflow.""" + diff --git a/review_agent/regulatory_info_package/constants.py b/review_agent/regulatory_info_package/constants.py new file mode 100644 index 0000000..adaf007 --- /dev/null +++ b/review_agent/regulatory_info_package/constants.py @@ -0,0 +1,30 @@ +WORKFLOW_TYPE = "regulatory_info_package" +DEFAULT_ZIP_NAME = "第1章 监管信息(预生成版).zip" + +REGULATORY_INFO_PACKAGE_TRIGGER_KEYWORDS = [ + "根据说明书生成第1章监管信息", + "生成监管信息材料包", + "从说明书生成第1章材料", + "第1章监管信息", + "监管信息材料包", +] + +REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS = [ + ("prepare", "准备资料", "regulatory_info_package"), + ("template_copy", "复制模板", "regulatory_info_package"), + ("text_extract", "抽取说明书", "regulatory_info_package"), + ("field_extract", "抽取字段", "regulatory_info_package"), + ("field_merge", "合并字段", "regulatory_info_package"), + ("generate_docs", "生成材料", "regulatory_info_package"), + ("highlight_review_items", "标记待确认", "regulatory_info_package"), + ("trace_export", "追溯清单", "regulatory_info_package"), + ("zip_export", "打包下载", "regulatory_info_package"), + ("notify", "通知", "regulatory_info_package"), + ("completed", "完成", "completed"), +] + +GENERATED_FILE_SUCCESS = "success" +GENERATED_FILE_FALLBACK_SUCCESS = "fallback_success" +GENERATED_FILE_FAILED = "failed" +GENERATED_FILE_SKIPPED = "skipped" + diff --git a/review_agent/regulatory_info_package/events.py b/review_agent/regulatory_info_package/events.py new file mode 100644 index 0000000..7d12e93 --- /dev/null +++ b/review_agent/regulatory_info_package/events.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from review_agent.regulatory_info_package.constants import WORKFLOW_TYPE +from review_agent.models import RegulatoryInfoPackageBatch, WorkflowEvent + + +def record_event(batch: RegulatoryInfoPackageBatch, event_type: str, payload: dict | None = None) -> WorkflowEvent: + return WorkflowEvent.objects.create( + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=batch.pk, + conversation=batch.conversation, + event_type=event_type, + payload=payload or {}, + ) + diff --git a/review_agent/regulatory_info_package/schemas.py b/review_agent/regulatory_info_package/schemas.py new file mode 100644 index 0000000..2f61dd2 --- /dev/null +++ b/review_agent/regulatory_info_package/schemas.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class TemplateSpec: + code: str + output_name: str + source_file: str + file_format: str + strategy: str + include_in_zip: bool + prefer_legacy_doc_native: bool = False + allow_docx_fallback: bool = True + fields: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class InstructionExtractResult: + source_file_name: str + paragraphs: list[str] + sections: dict[str, str] + tables: list[list[list[str]]] + component_tables: list[dict[str, Any]] + front_text: str + + +@dataclass +class MergedField: + key: str + label: str + value: str + source: str + evidence: str + confidence: float + highlight_reason: str = "none" + needs_review: bool = False + rule_value: str = "" + llm_value: str = "" + + +@dataclass +class GeneratedFileResult: + template_code: str + file_name: str + requested_format: str + actual_format: str + status: str + path: str = "" + artifact_id: int | None = None + export_id: int | None = None + highlight_count: int = 0 + missing_count: int = 0 + llm_only_count: int = 0 + error_message: str = "" + diff --git a/review_agent/regulatory_info_package/services/__init__.py b/review_agent/regulatory_info_package/services/__init__.py new file mode 100644 index 0000000..0f7ff23 --- /dev/null +++ b/review_agent/regulatory_info_package/services/__init__.py @@ -0,0 +1,2 @@ +"""Services for the regulatory information package workflow.""" + diff --git a/review_agent/regulatory_info_package/services/docx_document.py b/review_agent/regulatory_info_package/services/docx_document.py new file mode 100644 index 0000000..c7b5629 --- /dev/null +++ b/review_agent/regulatory_info_package/services/docx_document.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path + +from docx import Document +from docx.enum.text import WD_COLOR_INDEX +from docx.shared import RGBColor +from django.utils import timezone + +from review_agent.regulatory_info_package.schemas import MergedField + + +PLACEHOLDER_RE = re.compile(r"\{\{([a-zA-Z0-9_]+)\}\}") + + +def write_docx_from_template( + source_path: str | Path, + output_path: str | Path, + merged_fields: dict[str, MergedField], + *, + template_code: str = "", + directory_page_numbers: dict[str, str] | None = None, +) -> tuple[int, int, int]: + source = Path(source_path) + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + if source.exists(): + document = Document(source) + else: + document = Document() + replacements = {f"{{{{{key}}}}}": field for key, field in merged_fields.items()} + highlight_count = 0 + missing_count = 0 + llm_only_count = 0 + highlight_count += _apply_known_template_replacements(document, merged_fields, template_code=template_code) + if template_code == "ch1_5_product_list": + _rebuild_product_list_table(document, merged_fields) + if template_code == "ch1_2_directory": + _apply_directory_page_numbers(document, directory_page_numbers or {}) + paragraph_counts = _replace_placeholders(document, replacements, merged_fields) + highlight_count += paragraph_counts[0] + missing_count += paragraph_counts[1] + llm_only_count += paragraph_counts[2] + document.save(output) + return highlight_count, missing_count, llm_only_count + + +def _replace_paragraph_text(paragraph, text: str, field: MergedField) -> None: + for run in paragraph.runs: + run.text = "" + run = paragraph.add_run(text) + if field.highlight_reason != "none": + run.font.highlight_color = WD_COLOR_INDEX.YELLOW + if field.highlight_reason == "conflict": + run.font.color.rgb = RGBColor(255, 0, 0) + + +def _apply_directory_page_numbers(document, page_numbers: dict[str, str]) -> None: + for table in document.tables: + if not table.rows: + continue + header = [cell.text.strip() for cell in table.rows[0].cells] + if len(header) < 5 or header[0] != "RPS目录" or header[4] != "页码": + continue + for row in table.rows[1:]: + code = row.cells[0].text.strip() + if code in page_numbers: + row.cells[4].text = page_numbers[code] + return + + +def _replace_placeholders( + document, + replacements: dict[str, MergedField], + merged_fields: dict[str, MergedField], +) -> tuple[int, int, int]: + highlight_count = 0 + missing_count = 0 + llm_only_count = 0 + for paragraph in _iter_paragraphs(document): + text = paragraph.text + if "{{" not in text or "}}" not in text: + continue + used_fields: list[MergedField] = [] + + def replace(match: re.Match[str]) -> str: + key = match.group(1) + placeholder = match.group(0) + field = replacements.get(placeholder) or _default_placeholder_field(key, merged_fields) + used_fields.append(field) + return field.value + + new_text = PLACEHOLDER_RE.sub(replace, text) + if new_text == text: + continue + field_for_style = next((field for field in used_fields if field.highlight_reason != "none"), None) or used_fields[0] + _replace_paragraph_text(paragraph, new_text, field_for_style) + for field in used_fields: + if field.highlight_reason != "none": + highlight_count += 1 + if field.highlight_reason == "missing": + missing_count += 1 + if field.highlight_reason == "llm_only": + llm_only_count += 1 + return highlight_count, missing_count, llm_only_count + + +def _iter_paragraphs(document): + yield from document.paragraphs + for table in document.tables: + for row in table.rows: + for cell in row.cells: + yield from cell.paragraphs + + +def _apply_known_template_replacements(document, merged_fields: dict[str, MergedField], *, template_code: str = "") -> int: + product = _field_value(merged_fields, "product_name") + applicant = _field_value(merged_fields, "applicant_name") + today = timezone.localdate().strftime("%Y年%m月%d日") + replacements = { + "xxxx年xx月xx日": today, + "XXXX年XX月XX日": today, + "xxxx 年 xx 月 xx 日": today, + "XXXX 年 XX 月 XX 日": today, + "2023年09月20日": today, + "2023 年 10 月": today[:8], + } + if not template_code.startswith("ch1_11"): + replacements.update({ + "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)": product, + "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒": product, + "呼吸道合胞病毒 、肺炎支产品名称: 原体核酸检测试剂盒(荧": f"产品名称:{product}", + "光PCR法)": "", + "卡尤迪生物科技宜兴有限公司": applicant, + }) + changed = 0 + for paragraph in document.paragraphs: + changed += _replace_text_in_paragraph(paragraph, replacements, merged_fields) + for table in document.tables: + for row in table.rows: + for cell in row.cells: + for paragraph in cell.paragraphs: + changed += _replace_text_in_paragraph(paragraph, replacements, merged_fields) + return changed + + +def _default_placeholder_field(key: str, merged_fields: dict[str, MergedField]) -> MergedField: + if key == "declaration_date": + return _plain_field(key, "日期", timezone.localdate().strftime("%Y年%m月%d日")) + label = key + for field in merged_fields.values(): + if field.key == key: + label = field.label + break + return MergedField( + key=key, + label=label, + value="/", + source="missing", + evidence="模板字段未从说明书中抽取到", + confidence=0.0, + highlight_reason="missing", + needs_review=True, + ) + + +def _replace_text_in_paragraph(paragraph, replacements: dict[str, str], merged_fields: dict[str, MergedField]) -> int: + text = paragraph.text + new_text = text + for old, new in replacements.items(): + if old in new_text: + new_text = new_text.replace(old, new) + if new_text == text: + return 0 + field = merged_fields.get("product_name") or MergedField( + key="product_name", + label="产品名称", + value=new_text, + source="rule", + evidence="", + confidence=0.0, + ) + _replace_paragraph_text(paragraph, new_text, field) + return 1 + + +def _rebuild_product_list_table(document, merged_fields: dict[str, MergedField]) -> None: + product = _field_value(merged_fields, "product_name") + package_specification = _field_value(merged_fields, "package_specification") + component_table = _component_table_payload(merged_fields) + component_notes = _field_value(merged_fields, "component_notes") + for paragraph in document.paragraphs: + if "的包装规格、货号、组分及主要组成成分见下表" in paragraph.text: + _replace_paragraph_text( + paragraph, + f"{product}的包装规格、货号、组分及主要组成成分见下表:", + merged_fields.get("product_name") or _plain_field("product_name", "产品名称", product), + ) + if "规格A和规格B的区别" in paragraph.text and component_notes != "/": + _replace_paragraph_text( + paragraph, + component_notes, + merged_fields.get("component_notes") or _plain_field("component_notes", "主要组成成分备注", component_notes), + ) + target = None + for table in document.tables: + header = [cell.text.strip() for cell in table.rows[0].cells] if table.rows else [] + if header[:6] == ["包装规格", "货号", "组成", "组分", "主要组成成分", "规格/数量"]: + target = table + break + specs = _component_specs(component_table) or [ + (spec, None) for spec in [item.strip() for item in package_specification.replace(";", ";").split(";") if item.strip()] + ] + if target is not None: + _clear_table_body(target) + if component_table: + _fill_product_component_table(target, component_table, specs) + else: + if not specs: + specs = [("/", None)] + for spec, _index in specs[:8]: + cells = target.add_row().cells + cells[0].text = spec + cells[1].text = "/" + cells[2].text = _field_value(merged_fields, "composition") + cells[3].text = _field_value(merged_fields, "component_name") + cells[4].text = _field_value(merged_fields, "main_component") + cells[5].text = _field_value(merged_fields, "quantity") + if component_table: + _rebuild_component_comparison_table(document, component_table, specs) + + +def _field_value(merged_fields: dict[str, MergedField], key: str) -> str: + field = merged_fields.get(key) + if not field or not field.value: + return "/" + return field.value + + +def _plain_field(key: str, label: str, value: str) -> MergedField: + return MergedField(key=key, label=label, value=value, source="rule", evidence="", confidence=0.0) + + +def _component_table_payload(merged_fields: dict[str, MergedField]) -> dict: + field = merged_fields.get("component_table") + if not field or not field.value or field.value == "/": + return {} + try: + payload = json.loads(field.value) + except json.JSONDecodeError: + return {} + if not isinstance(payload, dict): + return {} + rows = payload.get("rows") or [] + header = payload.get("header") or [] + if not isinstance(header, list) or not isinstance(rows, list): + return {} + return {"header": header, "rows": rows} + + +def _component_specs(component_table: dict) -> list[tuple[str, int]]: + header = component_table.get("header") or [] + specs: list[tuple[str, int]] = [] + for index, value in enumerate(header[2:], start=2): + label = str(value or "").strip() + if not label: + continue + label = label.replace("规格(", "").replace("规格(", "").rstrip("))") + specs.append((label, index)) + return specs + + +def _clear_table_body(table) -> None: + while len(table.rows) > 1: + table._tbl.remove(table.rows[-1]._tr) + + +def _fill_product_component_table(table, component_table: dict, specs: list[tuple[str, int]]) -> None: + rows = component_table.get("rows") or [] + for spec_label, spec_index in specs: + for row in rows: + cells = table.add_row().cells + cells[0].text = spec_label + cells[1].text = "/" + cells[2].text = "/" + cells[3].text = _row_value(row, 0) + cells[4].text = _row_value(row, 1) + cells[5].text = _row_value(row, spec_index or 0) + + +def _rebuild_component_comparison_table(document, component_table: dict, specs: list[tuple[str, int]]) -> None: + target = None + for table in document.tables: + header = [cell.text.strip() for cell in table.rows[0].cells] if table.rows else [] + if header and header[0] == "组分名称": + target = table + break + if target is None: + return + _clear_table_body(target) + header_cells = target.rows[0].cells + labels = ["组分名称", *[spec for spec, _index in specs[: len(header_cells) - 1]]] + while len(labels) < len(header_cells): + labels.append("备注") + for index, label in enumerate(labels[: len(header_cells)]): + header_cells[index].text = label + for row in component_table.get("rows") or []: + cells = target.add_row().cells + cells[0].text = _row_value(row, 0) + for cell_index, (_spec_label, spec_index) in enumerate(specs[: len(cells) - 1], start=1): + cells[cell_index].text = _row_value(row, spec_index) + for cell_index in range(len(specs[: len(cells) - 1]) + 1, len(cells)): + cells[cell_index].text = "/" + + +def _row_value(row, index: int) -> str: + if not isinstance(row, list) or index >= len(row): + return "/" + value = str(row[index] or "").strip() + return value or "/" diff --git a/review_agent/regulatory_info_package/services/field_extract.py b/review_agent/regulatory_info_package/services/field_extract.py new file mode 100644 index 0000000..d2342d3 --- /dev/null +++ b/review_agent/regulatory_info_package/services/field_extract.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import json +import re +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Callable + +from review_agent.llm import generate_completion +from review_agent.regulatory_info_package.schemas import InstructionExtractResult + + +FIELD_PATTERNS = { + "product_name": ("产品名称", r"产品名称[::\s]*([^\n\r]+)"), + "applicant_name": ("申请人名称", r"(?:申请人名称|注册人/售后服务单位名称|注册人名称|售后服务单位名称|生产企业名称)[::\s]*([^\n\r]+)"), + "manufacturer_name": ("生产企业名称", r"生产企业名称[::\s]*([^\n\r]+)"), + "applicant_address": ("申请人住所", r"(?:申请人住所|注册人住所|生产企业住所)[::\s]*([^\n\r]+)"), + "applicant_contact": ("申请人联系方式", r"(?:联系方式|联系电话|电话)[::\s]*([^\n\r]+)"), + "production_address": ("生产地址", r"生产地址[::\s]*([^\n\r]+)"), + "storage_condition": ("储存条件", r"(?:储存条件|贮存条件|保存条件)[::\s]*([^\n\r]+)"), + "intended_use": ("预期用途", r"预期用途[::\s]*([^\n\r]+)"), + "package_specification": ("包装规格", r"(?:包装规格|规格)[::\s]*([^\n\r]+)"), + "sample_type": ("样本类型", r"样本类型[::\s]*([^\n\r]+)"), + "applicable_instrument": ("适用仪器", r"适用仪器[::\s]*([^\n\r]+)"), + "standard_no": ("标准号", r"((?:GB|YY|WS|T/C[A-Z0-9]*)[ /T0-9.\-—]+)"), +} + + +def extract_fields_by_rules(instruction: InstructionExtractResult) -> dict[str, dict]: + text = "\n".join([instruction.front_text, *instruction.paragraphs, *instruction.sections.values()]) + results: dict[str, dict] = {} + for key, (label, pattern) in FIELD_PATTERNS.items(): + section_value = _value_after_label_paragraph(instruction.paragraphs, label) + if section_value: + results[key] = { + "label": label, + "value": section_value, + "evidence": f"【{label}】\n{section_value}", + "confidence": 0.82, + "source": "rule", + } + continue + match = re.search(pattern, text, flags=re.IGNORECASE) + if match: + value = _clean_value(match.group(1)) + if value: + results[key] = { + "label": label, + "value": value, + "evidence": match.group(0)[:240], + "confidence": 0.75, + "source": "rule", + } + component_table = _best_component_table(instruction.component_tables) + if component_table: + results["component_table"] = { + "label": "主要组成成分", + "value": json.dumps(component_table, ensure_ascii=False), + "evidence": "说明书【主要组成成分】表格", + "confidence": 0.86, + "source": "rule", + } + component_notes = _component_notes(instruction.sections) + if component_notes: + results["component_notes"] = { + "label": "主要组成成分备注", + "value": component_notes, + "evidence": "说明书【主要组成成分】段落", + "confidence": 0.8, + "source": "rule", + } + return results + + +def extract_fields_with_llm(instruction: InstructionExtractResult) -> dict[str, dict]: + prompt = ( + "请从体外诊断试剂产品说明书中抽取字段,输出 JSON 对象,字段包括 " + "product_name、storage_condition、intended_use、package_specification、sample_type、applicable_instrument、standard_no。" + "每个字段值为 {label,value,evidence,confidence}。\n\n" + + instruction.front_text[:6000] + ) + raw = generate_completion([{"role": "user", "content": prompt}], temperature=0.0) + payload = _parse_json_object(raw) + return {key: value for key, value in payload.items() if isinstance(value, dict)} + + +def run_llm_extract_with_retry( + instruction: InstructionExtractResult, + *, + llm_extract_func: Callable[[InstructionExtractResult], dict[str, dict]] | None = None, + sleep_func: Callable[[float], None] = time.sleep, +) -> dict[str, dict]: + func = llm_extract_func or extract_fields_with_llm + last_exc: Exception | None = None + for delay in [0, 1, 2]: + if delay: + sleep_func(delay) + try: + return func(instruction) + except Exception as exc: + last_exc = exc + if last_exc: + raise last_exc + return {} + + +def run_parallel_extract( + instruction: InstructionExtractResult, + *, + llm_extract_func: Callable[[InstructionExtractResult], dict[str, dict]] | None = None, +) -> dict: + payload = {"regex_results": {}, "llm_results": {}, "llm_error": ""} + with ThreadPoolExecutor(max_workers=2) as executor: + rule_future = executor.submit(extract_fields_by_rules, instruction) + llm_future = executor.submit(run_llm_extract_with_retry, instruction, llm_extract_func=llm_extract_func) + payload["regex_results"] = rule_future.result() + try: + payload["llm_results"] = llm_future.result() + except Exception as exc: + payload["llm_error"] = str(exc) + return payload + + +def save_field_extract_result(path: str | Path, payload: dict) -> Path: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return target + + +def _clean_value(value: str) -> str: + cleaned = value.strip() + if cleaned in {"】", "】】", "】:"}: + return "" + return re.split(r"[。;;]", cleaned)[0].strip() + + +def _value_after_label_paragraph(paragraphs: list[str], label: str) -> str: + bracketed = {f"【{label}】", f"[{label}]", label} + for index, text in enumerate(paragraphs): + stripped = text.strip() + if stripped in bracketed and index + 1 < len(paragraphs): + return _clean_value(paragraphs[index + 1]) + return "" + + +def _parse_json_object(raw: str) -> dict: + text = (raw or "").strip() + if text.startswith("```"): + text = text.strip("`").strip() + if text.lower().startswith("json"): + text = text[4:].strip() + start = text.find("{") + end = text.rfind("}") + if start == -1 or end == -1: + return {} + return json.loads(text[start : end + 1]) + + +def _best_component_table(component_tables: list[dict]) -> dict: + if not component_tables: + return {} + return max(component_tables, key=lambda table: len(table.get("rows") or [])) + + +def _component_notes(sections: dict[str, str]) -> str: + for key, value in sections.items(): + if "主要组成" in key: + return value.strip() + return "" diff --git a/review_agent/regulatory_info_package/services/field_merge.py b/review_agent/regulatory_info_package/services/field_merge.py new file mode 100644 index 0000000..5e9aff7 --- /dev/null +++ b/review_agent/regulatory_info_package/services/field_merge.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from review_agent.regulatory_info_package.schemas import MergedField + + +REQUIRED_FIELDS = { + "product_name": "产品名称", + "applicant_name": "申请人名称", + "package_specification": "包装规格", + "intended_use": "预期用途", + "storage_condition": "储存条件", +} + + +def merge_fields(rule_results: dict[str, dict], llm_results: dict[str, dict]) -> tuple[dict[str, MergedField], dict[str, list[dict]]]: + merged: dict[str, MergedField] = {} + missing_fields: list[dict] = [] + llm_only_fields: list[dict] = [] + conflict_fields: list[dict] = [] + keys = set(REQUIRED_FIELDS) | set(rule_results) | set(llm_results) + for key in sorted(keys): + rule = rule_results.get(key) or {} + llm = llm_results.get(key) or {} + rule_value = str(rule.get("value") or "").strip() + llm_value = str(llm.get("value") or "").strip() + label = str(rule.get("label") or llm.get("label") or REQUIRED_FIELDS.get(key) or key) + if rule_value and llm_value and rule_value != llm_value: + field = MergedField( + key=key, + label=label, + value=rule_value, + source="rule_conflict", + evidence=str(rule.get("evidence") or ""), + confidence=float(rule.get("confidence") or 0.0), + highlight_reason="conflict", + needs_review=True, + rule_value=rule_value, + llm_value=llm_value, + ) + conflict_fields.append( + { + "field_key": key, + "field_label": label, + "rule_value": rule_value, + "llm_value": llm_value, + "selected_value": rule_value, + "handling": "规则优先,写入值高亮并进入追溯清单", + } + ) + elif rule_value: + field = MergedField( + key=key, + label=label, + value=rule_value, + source="rule", + evidence=str(rule.get("evidence") or ""), + confidence=float(rule.get("confidence") or 0.0), + ) + elif llm_value: + field = MergedField( + key=key, + label=label, + value=llm_value, + source="llm", + evidence=str(llm.get("evidence") or ""), + confidence=float(llm.get("confidence") or 0.0), + highlight_reason="llm_only", + needs_review=True, + llm_value=llm_value, + ) + llm_only_fields.append(_review_dict(field)) + else: + field = MergedField( + key=key, + label=label, + value="/", + source="missing", + evidence="", + confidence=0.0, + highlight_reason="missing", + needs_review=True, + ) + missing_fields.append(_review_dict(field)) + merged[key] = field + return merged, { + "missing_fields": missing_fields, + "llm_only_fields": llm_only_fields, + "conflict_fields": conflict_fields, + } + + +def save_merged_fields(path: str | Path, merged: dict[str, MergedField], summary: dict[str, list[dict]]) -> Path: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + payload = { + "fields": {key: field.__dict__ for key, field in merged.items()}, + **summary, + } + target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return target + + +def _review_dict(field: MergedField) -> dict: + return { + "target_file": "", + "field_key": field.key, + "field_label": field.label, + "final_value": field.value, + "highlight_reason": field.highlight_reason, + "needs_review": field.needs_review, + } + diff --git a/review_agent/regulatory_info_package/services/input_select.py b/review_agent/regulatory_info_package/services/input_select.py new file mode 100644 index 0000000..a269ab4 --- /dev/null +++ b/review_agent/regulatory_info_package/services/input_select.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from review_agent.models import Conversation, FileAttachment, FileSummaryBatch, FileSummaryItem + + +@dataclass +class InstructionInputSelection: + status: str + file_name: str = "" + storage_path: str = "" + attachment: FileAttachment | None = None + source_summary_batch: FileSummaryBatch | None = None + source_summary_item_id: int | None = None + candidates: list[str] = field(default_factory=list) + message: str = "" + + +def select_instruction_input(conversation: Conversation, message: str) -> InstructionInputSelection: + candidates = _active_docx_attachments(conversation) + named = _match_by_message(candidates, message) + if len(named) == 1: + return _selection_from_attachment(named[0]) + instruction_candidates = [item for item in candidates if "说明书" in item.original_name] + if len(instruction_candidates) == 1: + return _selection_from_attachment(instruction_candidates[0]) + if len(candidates) == 1: + return _selection_from_attachment(candidates[0]) + if len(instruction_candidates) > 1 or len(candidates) > 1: + names = [item.original_name for item in (instruction_candidates or candidates)] + return InstructionInputSelection( + status="waiting_user", + candidates=names, + message="请确认用于生成第1章监管信息的说明书文件名:" + "、".join(names), + ) + summary_selection = _select_from_latest_summary(conversation, message) + if summary_selection: + return summary_selection + return InstructionInputSelection(status="missing", message="请先上传产品说明书 docx 文件。") + + +def _active_docx_attachments(conversation: Conversation) -> list[FileAttachment]: + return list( + FileAttachment.objects.filter( + conversation=conversation, + is_active=True, + ) + .exclude(upload_status=FileAttachment.UploadStatus.DELETED) + .filter(original_name__iendswith=".docx") + .order_by("original_name", "-version_no") + ) + + +def _match_by_message(candidates: list[FileAttachment], message: str) -> list[FileAttachment]: + compact = "".join((message or "").lower().split()) + matched = [] + for attachment in candidates: + stem = Path(attachment.original_name).stem.lower() + name = attachment.original_name.lower() + if stem and stem in compact or name and name in compact: + matched.append(attachment) + return matched + + +def _selection_from_attachment(attachment: FileAttachment) -> InstructionInputSelection: + return InstructionInputSelection( + status="selected", + file_name=attachment.original_name, + storage_path=attachment.storage_path, + attachment=attachment, + ) + + +def _select_from_latest_summary(conversation: Conversation, message: str) -> InstructionInputSelection | None: + batch = ( + FileSummaryBatch.objects.filter(conversation=conversation, status=FileSummaryBatch.Status.SUCCESS) + .order_by("-finished_at", "-created_at", "-id") + .first() + ) + if not batch: + return None + items = list(batch.items.filter(file_name__iendswith=".docx").order_by("file_name", "id")) + compact = "".join((message or "").lower().split()) + named = [item for item in items if Path(item.file_name).stem.lower() in compact or item.file_name.lower() in compact] + candidates = named or [item for item in items if "说明书" in item.file_name] + if len(candidates) == 1: + item = candidates[0] + return InstructionInputSelection( + status="selected", + file_name=item.file_name, + storage_path=item.storage_path, + source_summary_batch=batch, + source_summary_item_id=item.pk, + ) + if len(candidates) > 1: + return InstructionInputSelection( + status="waiting_user", + source_summary_batch=batch, + candidates=[item.file_name for item in candidates], + message="请确认用于生成第1章监管信息的说明书文件名:" + "、".join(item.file_name for item in candidates), + ) + return None + diff --git a/review_agent/regulatory_info_package/services/instruction_extract.py b/review_agent/regulatory_info_package/services/instruction_extract.py new file mode 100644 index 0000000..9a3829e --- /dev/null +++ b/review_agent/regulatory_info_package/services/instruction_extract.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from docx import Document + +from review_agent.regulatory_info_package.schemas import InstructionExtractResult + + +def parse_instruction_docx(path: str | Path) -> InstructionExtractResult: + file_path = Path(path) + document = Document(file_path) + paragraphs = [paragraph.text.strip() for paragraph in document.paragraphs if paragraph.text.strip()] + tables = [] + for table in document.tables: + rows = [] + for row in table.rows: + rows.append([" ".join(cell.text.split()) for cell in row.cells]) + if rows: + tables.append(rows) + sections = _build_sections(paragraphs) + front_text = "\n".join(paragraphs[:30]) + return InstructionExtractResult( + source_file_name=file_path.name, + paragraphs=paragraphs, + sections=sections, + tables=tables, + component_tables=_component_tables(tables), + front_text=front_text, + ) + + +def save_instruction_extract_json(path: str | Path, result: InstructionExtractResult) -> Path: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + payload = { + "source_file_name": result.source_file_name, + "paragraphs": result.paragraphs, + "sections": result.sections, + "tables": result.tables, + "component_tables": result.component_tables, + "front_text": result.front_text, + } + target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return target + + +def _build_sections(paragraphs: list[str]) -> dict[str, str]: + sections: dict[str, list[str]] = {} + current = "front" + for text in paragraphs: + if _looks_like_heading(text): + current = text[:80] + sections.setdefault(current, []) + continue + sections.setdefault(current, []).append(text) + return {key: "\n".join(value).strip() for key, value in sections.items() if value} + + +def _looks_like_heading(text: str) -> bool: + compact = text.strip() + if len(compact) > 40: + return False + heading_markers = ("一、", "二、", "三、", "四、", "五、", "六、", "【", "产品名称", "预期用途", "主要组成") + return compact.startswith(heading_markers) + + +def _component_tables(tables: list[list[list[str]]]) -> list[dict]: + results = [] + for table in tables: + header = table[0] if table else [] + joined = "".join(header) + if any(keyword in joined for keyword in ["组成", "组分", "成分"]): + results.append({"header": header, "rows": table[1:]}) + return results + diff --git a/review_agent/regulatory_info_package/services/legacy_doc_document.py b/review_agent/regulatory_info_package/services/legacy_doc_document.py new file mode 100644 index 0000000..f95d25c --- /dev/null +++ b/review_agent/regulatory_info_package/services/legacy_doc_document.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import shutil +from dataclasses import dataclass +from pathlib import Path + +from django.conf import settings +from docx import Document + +from review_agent.regulatory_info_package.schemas import MergedField + + +@dataclass(frozen=True) +class LegacyDocCapability: + status: str + adapter: str + message: str = "" + + +def detect_legacy_doc_capability() -> LegacyDocCapability: + try: + import win32com.client # noqa: F401 + + return LegacyDocCapability(status="available", adapter="WordComDocAdapter", message="Word COM 可用") + except Exception as exc: + return LegacyDocCapability( + status="unavailable", + adapter="UnavailableLegacyDocAdapter", + message=f"Word COM 不可用:{type(exc).__name__}", + ) + + +def write_legacy_doc_or_fallback( + source_path: str | Path, + output_path: str | Path, + merged_fields: dict[str, MergedField], +) -> tuple[Path, str, dict]: + source = Path(source_path) + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + capability = detect_legacy_doc_capability() + native_enabled = bool(getattr(settings, "REGULATORY_INFO_PACKAGE_ENABLE_WORD_COM_NATIVE", False)) + if native_enabled and capability.status == "available" and source.exists(): + shutil.copy2(source, output) + try: + _append_doc_summary_with_word_com(output, merged_fields) + return output, "success", {"doc": capability.__dict__, "fallback_used": False, "native_write": True} + except Exception as exc: + capability = LegacyDocCapability( + status="unavailable", + adapter="UnavailableLegacyDocAdapter", + message=f"Word COM 写入失败:{exc}", + ) + fallback = output.with_suffix(".docx") + document = Document() + heading = document.add_paragraph() + heading.add_run(output.stem).bold = True + document.add_paragraph("【预生成版】当前未启用 .doc 原生写入,已生成 docx 兜底文件。") + for field in merged_fields.values(): + document.add_paragraph(f"{field.label}:{field.value}") + document.save(fallback) + return fallback, "fallback_success", {"doc": capability.__dict__, "fallback_used": True, "native_enabled": native_enabled} + + +def _append_doc_summary_with_word_com(path: Path, merged_fields: dict[str, MergedField]) -> None: + import win32com.client + + word = win32com.client.Dispatch("Word.Application") + word.Visible = False + document = None + try: + document = word.Documents.Open(str(path.resolve())) + end_range = document.Range(document.Content.End - 1, document.Content.End - 1) + lines = ["", "【预生成版】以下字段由系统根据说明书预填,请人工复核。"] + lines.extend(f"{field.label}:{field.value}" for field in merged_fields.values()) + end_range.InsertAfter("\r".join(lines)) + document.Save() + finally: + if document is not None: + document.Close(False) + word.Quit() diff --git a/review_agent/regulatory_info_package/services/package_generate.py b/review_agent/regulatory_info_package/services/package_generate.py new file mode 100644 index 0000000..6b11ccc --- /dev/null +++ b/review_agent/regulatory_info_package/services/package_generate.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import subprocess +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from zipfile import ZipFile +from xml.etree import ElementTree + +from review_agent.models import RegulatoryInfoPackageBatch +from review_agent.regulatory_info_package.constants import GENERATED_FILE_FAILED +from review_agent.regulatory_info_package.schemas import GeneratedFileResult, MergedField, TemplateSpec +from review_agent.regulatory_info_package.services.docx_document import write_docx_from_template +from review_agent.regulatory_info_package.services.legacy_doc_document import write_legacy_doc_or_fallback +from review_agent.regulatory_info_package.services.template_repository import copy_template_to_batch, template_specs +from review_agent.regulatory_info_package.storage import ensure_batch_subdir + + +def generate_package_documents( + batch: RegulatoryInfoPackageBatch, + config: dict, + merged_fields: dict[str, MergedField], +) -> list[GeneratedFileResult]: + specs = template_specs(config) + directory_specs = [spec for spec in specs if spec.code == "ch1_2_directory"] + content_specs = [spec for spec in specs if spec.code != "ch1_2_directory"] + results: list[GeneratedFileResult] = [] + with ThreadPoolExecutor(max_workers=min(4, len(content_specs) or 1)) as executor: + futures = [executor.submit(_generate_one, batch, config, spec, merged_fields) for spec in content_specs] + results.extend(future.result() for future in as_completed(futures)) + page_numbers = _directory_page_numbers(results) + for spec in directory_specs: + results.append(_generate_one(batch, config, spec, merged_fields, directory_page_numbers=page_numbers)) + return results + + +def _generate_one( + batch: RegulatoryInfoPackageBatch, + config: dict, + spec: TemplateSpec, + merged_fields: dict[str, MergedField], + *, + directory_page_numbers: dict[str, str] | None = None, +) -> GeneratedFileResult: + try: + template_path = copy_template_to_batch(batch, config, spec) + generated_dir = ensure_batch_subdir(batch, "generated") + output_path = generated_dir / spec.output_name + adapter_summary = {} + if spec.file_format == "doc": + actual_path, status, adapter_summary = write_legacy_doc_or_fallback(template_path, output_path, merged_fields) + actual_format = actual_path.suffix.lower().lstrip(".") + highlight_count = missing_count = llm_only_count = 0 + else: + highlight_count, missing_count, llm_only_count = write_docx_from_template( + template_path, + output_path, + merged_fields, + template_code=spec.code, + directory_page_numbers=directory_page_numbers, + ) + actual_path = output_path + actual_format = "docx" + status = "success" + return GeneratedFileResult( + template_code=spec.code, + file_name=actual_path.name, + requested_format=spec.file_format, + actual_format=actual_format, + status=status, + path=str(actual_path), + highlight_count=highlight_count, + missing_count=missing_count, + llm_only_count=llm_only_count, + ) + except Exception as exc: + return GeneratedFileResult( + template_code=spec.code, + file_name=spec.output_name, + requested_format=spec.file_format, + actual_format=spec.file_format, + status=GENERATED_FILE_FAILED, + error_message=str(exc), + ) + + +def _directory_page_numbers(results: list[GeneratedFileResult]) -> dict[str, str]: + page_numbers = {"CH1.2": "1"} + for result in results: + if result.status not in {"success", "fallback_success"} or not result.path: + continue + code = _directory_code_from_file_name(result.file_name) + if not code: + continue + page_numbers[code] = str(count_document_pages(result.path)) + return page_numbers + + +def _directory_code_from_file_name(file_name: str) -> str: + stem = Path(file_name).stem.strip() + return stem.split()[0] if stem.startswith("CH") else "" + + +def count_document_pages(path: str | Path) -> int: + file_path = Path(path) + if not file_path.exists(): + return 1 + pages = _count_pages_from_docx_properties(file_path) + if pages: + return pages + pages = _count_pages_with_pywin32(file_path) + if pages: + return pages + pages = _count_pages_with_powershell_word(file_path) + if pages: + return pages + return 1 + + +def _count_pages_from_docx_properties(file_path: Path) -> int: + if file_path.suffix.lower() != ".docx": + return 0 + try: + with ZipFile(file_path) as archive: + root = ElementTree.fromstring(archive.read("docProps/app.xml")) + namespace = {"ep": "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"} + pages = root.find("ep:Pages", namespace) + return max(int((pages.text or "").strip()), 1) if pages is not None else 0 + except Exception: + return 0 + + +def _count_pages_with_pywin32(file_path: Path) -> int: + try: + import win32com.client + + word = win32com.client.DispatchEx("Word.Application") + word.Visible = False + document = None + try: + document = word.Documents.Open(str(file_path.resolve()), ReadOnly=True) + document.Repaginate() + return max(int(document.ComputeStatistics(2)), 1) + finally: + if document is not None: + document.Close(False) + word.Quit() + except Exception: + return 0 + + +def _count_pages_with_powershell_word(file_path: Path) -> int: + script = r""" +param([string]$Path) +$word = $null +$doc = $null +try { + $word = New-Object -ComObject Word.Application + $word.Visible = $false + $doc = $word.Documents.Open($Path, $false, $true) + $doc.Repaginate() + [Console]::Out.Write($doc.ComputeStatistics(2)) + exit 0 +} catch { + [Console]::Error.Write($_.Exception.Message) + exit 1 +} finally { + if ($doc -ne $null) { $doc.Close($false) | Out-Null } + if ($word -ne $null) { $word.Quit() | Out-Null } +} +""" + try: + completed = subprocess.run( + ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script, str(file_path.resolve())], + capture_output=True, + check=False, + text=True, + timeout=8, + ) + except Exception: + return 0 + if completed.returncode != 0: + return 0 + try: + return max(int(completed.stdout.strip()), 1) + except ValueError: + return 0 diff --git a/review_agent/regulatory_info_package/services/summary.py b/review_agent/regulatory_info_package/services/summary.py new file mode 100644 index 0000000..490704c --- /dev/null +++ b/review_agent/regulatory_info_package/services/summary.py @@ -0,0 +1,12 @@ +from __future__ import annotations + + +def build_assistant_summary(*, batch_no: str, exports: list[dict], failed_files: list[dict]) -> str: + zip_exports = [item for item in exports if item.get("export_type") == "zip" or str(item.get("file_name", "")).endswith(".zip")] + other_exports = [item for item in exports if item not in zip_exports] + lines = [f"已完成第1章监管信息材料包生成,批次号:{batch_no}。", ""] + for export in [*zip_exports, *other_exports]: + lines.append(f"- [{export['file_name']}]({export['download_url']})") + for failed in failed_files: + lines.append(f"- {failed.get('file_name')}:生成失败,{failed.get('error_message') or '原因待查看'}") + return "\n".join(lines) diff --git a/review_agent/regulatory_info_package/services/template_config.py b/review_agent/regulatory_info_package/services/template_config.py new file mode 100644 index 0000000..42475f9 --- /dev/null +++ b/review_agent/regulatory_info_package/services/template_config.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path + +import yaml +from django.conf import settings + + +CONFIG_PATH = Path(__file__).resolve().parents[1] / "templates" / "regulatory_info_package_templates_v1.yaml" + + +def load_template_config(path: str | Path | None = None) -> dict: + config_path = Path(path) if path else CONFIG_PATH + with config_path.open("r", encoding="utf-8") as handle: + payload = yaml.safe_load(handle) or {} + if payload.get("source_dir"): + payload["source_dir"] = str((Path(settings.BASE_DIR) / payload["source_dir"]).resolve()) + return payload + + +def compute_config_hash(path: str | Path | None = None) -> str: + config_path = Path(path) if path else CONFIG_PATH + digest = hashlib.sha256() + digest.update(config_path.read_bytes()) + return digest.hexdigest() + + +def validate_template_config(config: dict) -> list[str]: + errors: list[str] = [] + source_dir = Path(config.get("source_dir") or "") + if not source_dir.exists(): + errors.append(f"模板源目录不存在:{source_dir}") + templates = config.get("templates") or [] + if len(templates) != 6: + errors.append("第1章监管信息模板配置必须包含 6 个模板。") + seen: set[str] = set() + for template in templates: + code = str(template.get("code") or "") + if not code: + errors.append("模板 code 不能为空。") + elif code in seen: + errors.append(f"模板 code 重复:{code}") + seen.add(code) + source_file = str(template.get("source_file") or "") + output_name = str(template.get("output_name") or "") + if not source_file: + errors.append(f"模板 {code} 缺少 source_file。") + elif source_dir.exists() and not (source_dir / source_file).exists(): + errors.append(f"模板源文件不存在:{source_file}") + if not output_name: + errors.append(f"模板 {code} 缺少 output_name。") + return errors diff --git a/review_agent/regulatory_info_package/services/template_repository.py b/review_agent/regulatory_info_package/services/template_repository.py new file mode 100644 index 0000000..4d7c15e --- /dev/null +++ b/review_agent/regulatory_info_package/services/template_repository.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +from review_agent.regulatory_info_package.schemas import TemplateSpec +from review_agent.regulatory_info_package.storage import ensure_batch_subdir +from review_agent.models import RegulatoryInfoPackageBatch + + +def template_specs(config: dict) -> list[TemplateSpec]: + return [ + TemplateSpec( + code=item["code"], + output_name=item["output_name"], + source_file=item["source_file"], + file_format=item.get("file_format", "docx"), + strategy=item.get("strategy", item["code"]), + include_in_zip=bool(item.get("include_in_zip", True)), + prefer_legacy_doc_native=bool(item.get("prefer_legacy_doc_native", False)), + allow_docx_fallback=bool(item.get("allow_docx_fallback", True)), + fields=item.get("fields") or [], + ) + for item in config.get("templates") or [] + ] + + +def copy_template_to_batch(batch: RegulatoryInfoPackageBatch, config: dict, spec: TemplateSpec) -> Path: + source_dir = Path(config["source_dir"]) + source = source_dir / spec.source_file + target = ensure_batch_subdir(batch, "templates") / f"{spec.code}.source{source.suffix}" + shutil.copy2(source, target) + return target + diff --git a/review_agent/regulatory_info_package/services/traceability_export.py b/review_agent/regulatory_info_package/services/traceability_export.py new file mode 100644 index 0000000..61e9111 --- /dev/null +++ b/review_agent/regulatory_info_package/services/traceability_export.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from openpyxl import Workbook + +from review_agent.regulatory_info_package.schemas import MergedField + + +HEADERS = [ + "target_file", + "target_field", + "final_value", + "extraction_source", + "evidence", + "highlight_reason", + "needs_review", +] + + +def save_traceability_exports(root: str | Path, merged_fields: dict[str, MergedField]) -> tuple[Path, Path]: + root_path = Path(root) + exports_dir = root_path / "exports" + logs_dir = root_path / "logs" + exports_dir.mkdir(parents=True, exist_ok=True) + logs_dir.mkdir(parents=True, exist_ok=True) + rows = [ + { + "target_file": "", + "target_field": field.label, + "final_value": field.value, + "extraction_source": field.source, + "evidence": field.evidence, + "highlight_reason": field.highlight_reason, + "needs_review": field.needs_review, + } + for field in merged_fields.values() + ] + excel_path = exports_dir / "traceability.xlsx" + workbook = Workbook() + sheet = workbook.active + sheet.title = "traceability" + sheet.append(HEADERS) + for row in rows: + sheet.append([row.get(header, "") for header in HEADERS]) + workbook.save(excel_path) + json_path = logs_dir / "traceability.json" + json_path.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8") + return excel_path, json_path + diff --git a/review_agent/regulatory_info_package/services/zip_export.py b/review_agent/regulatory_info_package/services/zip_export.py new file mode 100644 index 0000000..2d13f1a --- /dev/null +++ b/review_agent/regulatory_info_package/services/zip_export.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile + +from review_agent.regulatory_info_package.constants import DEFAULT_ZIP_NAME, GENERATED_FILE_FALLBACK_SUCCESS, GENERATED_FILE_SUCCESS +from review_agent.regulatory_info_package.schemas import GeneratedFileResult + + +def create_zip_package(root: str | Path, generated_files: list[GeneratedFileResult], zip_name: str = DEFAULT_ZIP_NAME) -> Path: + root_path = Path(root) + exports_dir = root_path / "exports" + exports_dir.mkdir(parents=True, exist_ok=True) + zip_path = exports_dir / zip_name + allowed = {GENERATED_FILE_SUCCESS, GENERATED_FILE_FALLBACK_SUCCESS} + with ZipFile(zip_path, "w", compression=ZIP_DEFLATED) as archive: + for result in generated_files: + if result.status not in allowed or not result.path: + continue + file_path = Path(result.path) + if file_path.exists(): + archive.write(file_path, arcname=result.file_name) + return zip_path diff --git a/review_agent/regulatory_info_package/storage.py b/review_agent/regulatory_info_package/storage.py new file mode 100644 index 0000000..c815f73 --- /dev/null +++ b/review_agent/regulatory_info_package/storage.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path + +from django.conf import settings + +from review_agent.models import RegulatoryInfoPackageArtifact, RegulatoryInfoPackageBatch + + +def build_batch_work_dir(batch: RegulatoryInfoPackageBatch | None = None, *, batch_no: str = "") -> Path: + if batch: + return ( + Path(settings.MEDIA_ROOT) + / "regulatory_info_package" + / str(batch.user_id) + / str(batch.conversation_id) + / batch.batch_no + ) + return Path(settings.MEDIA_ROOT) / "regulatory_info_package" / batch_no + + +def ensure_batch_subdir(batch: RegulatoryInfoPackageBatch, name: str) -> Path: + root = Path(batch.work_dir) if batch.work_dir else build_batch_work_dir(batch) + target = root / Path(name).name + ensure_within_work_dir(batch, target) + target.mkdir(parents=True, exist_ok=True) + return target + + +def ensure_within_work_dir(batch: RegulatoryInfoPackageBatch, path: str | Path) -> Path: + root = Path(batch.work_dir).resolve() + target = Path(path).resolve() + if root != target and root not in target.parents: + raise ValueError("输出路径必须位于当前材料包批次工作目录内。") + return target + + +def compute_file_sha256(path: str | Path) -> str: + file_path = Path(path) + digest = hashlib.sha256() + with file_path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def create_artifact_for_file( + batch: RegulatoryInfoPackageBatch, + *, + path: str | Path, + artifact_type: str, + file_format: str, + name: str = "", + metadata: dict | None = None, + created_by_node: str = "", +) -> RegulatoryInfoPackageArtifact: + file_path = ensure_within_work_dir(batch, path) + return RegulatoryInfoPackageArtifact.objects.create( + batch=batch, + artifact_type=artifact_type, + file_format=file_format, + name=name or file_path.stem, + file_name=file_path.name, + storage_path=str(file_path), + file_size=file_path.stat().st_size if file_path.exists() else 0, + content_hash=compute_file_sha256(file_path) if file_path.exists() else "", + metadata=metadata or {}, + created_by_node=created_by_node, + ) + diff --git a/review_agent/regulatory_info_package/templates/clean/CH1.11.1 符合标准的清单.docx b/review_agent/regulatory_info_package/templates/clean/CH1.11.1 符合标准的清单.docx new file mode 100644 index 0000000..dc874a5 Binary files /dev/null and b/review_agent/regulatory_info_package/templates/clean/CH1.11.1 符合标准的清单.docx differ diff --git a/review_agent/regulatory_info_package/templates/clean/CH1.11.5 真实性声明.docx b/review_agent/regulatory_info_package/templates/clean/CH1.11.5 真实性声明.docx new file mode 100644 index 0000000..4fac204 Binary files /dev/null and b/review_agent/regulatory_info_package/templates/clean/CH1.11.5 真实性声明.docx differ diff --git a/review_agent/regulatory_info_package/templates/clean/CH1.11.6 符合性声明.docx b/review_agent/regulatory_info_package/templates/clean/CH1.11.6 符合性声明.docx new file mode 100644 index 0000000..2b29f3f Binary files /dev/null and b/review_agent/regulatory_info_package/templates/clean/CH1.11.6 符合性声明.docx differ diff --git a/review_agent/regulatory_info_package/templates/clean/CH1.2 监管信息目录 - 页码版.docx b/review_agent/regulatory_info_package/templates/clean/CH1.2 监管信息目录 - 页码版.docx new file mode 100644 index 0000000..4e8c239 Binary files /dev/null and b/review_agent/regulatory_info_package/templates/clean/CH1.2 监管信息目录 - 页码版.docx differ diff --git a/review_agent/regulatory_info_package/templates/clean/CH1.4 申请表 - 复选框调整版.docx b/review_agent/regulatory_info_package/templates/clean/CH1.4 申请表 - 复选框调整版.docx new file mode 100644 index 0000000..565a9b0 Binary files /dev/null and b/review_agent/regulatory_info_package/templates/clean/CH1.4 申请表 - 复选框调整版.docx differ diff --git a/review_agent/regulatory_info_package/templates/clean/CH1.5 产品列表.docx b/review_agent/regulatory_info_package/templates/clean/CH1.5 产品列表.docx new file mode 100644 index 0000000..7b08002 Binary files /dev/null and b/review_agent/regulatory_info_package/templates/clean/CH1.5 产品列表.docx differ diff --git a/review_agent/regulatory_info_package/templates/clean/CH1.9 产品申报前沟通的说明.docx b/review_agent/regulatory_info_package/templates/clean/CH1.9 产品申报前沟通的说明.docx new file mode 100644 index 0000000..112ee12 Binary files /dev/null and b/review_agent/regulatory_info_package/templates/clean/CH1.9 产品申报前沟通的说明.docx differ diff --git a/review_agent/regulatory_info_package/templates/regulatory_info_package_templates_v1.yaml b/review_agent/regulatory_info_package/templates/regulatory_info_package_templates_v1.yaml new file mode 100644 index 0000000..275a1a2 --- /dev/null +++ b/review_agent/regulatory_info_package/templates/regulatory_info_package_templates_v1.yaml @@ -0,0 +1,64 @@ +version: regulatory_info_package_templates_v1 +source_dir: review_agent/regulatory_info_package/templates/clean +zip_name: 第1章 监管信息(预生成版).zip +templates: + - code: ch1_2_directory + source_file: CH1.2 监管信息目录 - 页码版.docx + output_name: CH1.2 监管信息目录.docx + file_format: docx + strategy: directory + include_in_zip: true + fields: [] + - code: ch1_4_application_form + source_file: CH1.4 申请表 - 复选框调整版.docx + output_name: CH1.4 申请表.docx + file_format: docx + strategy: application_form + include_in_zip: true + fields: + - key: product_name + label: 产品名称 + placeholder: "{{product_name}}" + - key: applicant_name + label: 申请人名称 + placeholder: "{{applicant_name}}" + - code: ch1_5_product_list + source_file: CH1.5 产品列表.docx + output_name: CH1.5 产品列表.docx + file_format: docx + strategy: product_list + include_in_zip: true + fields: + - key: package_specification + label: 包装规格 + placeholder: "{{package_specification}}" + - code: ch1_11_1_standards + source_file: CH1.11.1 符合标准的清单.docx + output_name: CH1.11.1 符合标准的清单.docx + file_format: docx + strategy: standards + include_in_zip: true + fields: + - key: standard_no + label: 标准号 + placeholder: "{{standard_no}}" + - code: ch1_11_5_authenticity + source_file: CH1.11.5 真实性声明.docx + output_name: CH1.11.5 真实性声明.docx + file_format: docx + strategy: authenticity + include_in_zip: true + fields: + - key: product_name + label: 产品名称 + placeholder: "{{product_name}}" + - code: ch1_11_6_conformity + source_file: CH1.11.6 符合性声明.docx + output_name: CH1.11.6 符合性声明.docx + file_format: docx + strategy: conformity + include_in_zip: true + fields: + - key: product_name + label: 产品名称 + placeholder: "{{product_name}}" diff --git a/review_agent/regulatory_info_package/views.py b/review_agent/regulatory_info_package/views.py new file mode 100644 index 0000000..662956f --- /dev/null +++ b/review_agent/regulatory_info_package/views.py @@ -0,0 +1,127 @@ +import json + +from django.contrib.auth.decorators import login_required +from django.conf import settings +from django.http import Http404, JsonResponse +from django.views.decorators.http import require_http_methods + +from review_agent.models import ExportedSummaryFile, RegulatoryInfoPackageBatch, WorkflowNodeRun +from review_agent.regulatory_info_package.constants import WORKFLOW_TYPE +from review_agent.regulatory_info_package.services.input_select import select_instruction_input +from review_agent.regulatory_info_package.workflow import ( + create_regulatory_info_package_batch, + start_regulatory_info_package_workflow, +) + + +@require_http_methods(["GET"]) +def health(request): + return JsonResponse({"workflow_type": WORKFLOW_TYPE, "status": "available"}) + + +@login_required +@require_http_methods(["POST"]) +def start(request): + try: + payload = json.loads(request.body.decode("utf-8") or "{}") + except json.JSONDecodeError: + return JsonResponse({"error": "JSON 格式错误。"}, status=400) + from review_agent.models import Conversation + + conversation = Conversation.objects.filter(pk=payload.get("conversation_id"), user=request.user).first() + if not conversation: + raise Http404("对话不存在。") + selection = select_instruction_input(conversation, str(payload.get("message") or "")) + if selection.status != "selected": + return JsonResponse( + {"status": selection.status, "message": selection.message, "candidates": selection.candidates}, + status=400, + ) + batch = create_regulatory_info_package_batch( + conversation=conversation, + user=request.user, + source_attachment=selection.attachment, + source_summary_batch=selection.source_summary_batch, + source_summary_item_id=selection.source_summary_item_id, + source_file_name=selection.file_name, + source_storage_path=selection.storage_path, + ) + start_regulatory_info_package_workflow(batch, async_run=getattr(settings, "REGULATORY_INFO_PACKAGE_ASYNC", True)) + return JsonResponse({"batch_id": batch.pk, "workflow_type": WORKFLOW_TYPE, "status": batch.status}) + + +@login_required +@require_http_methods(["GET"]) +def batch_status(request, batch_id: int): + batch = RegulatoryInfoPackageBatch.objects.filter( + pk=batch_id, + conversation__user=request.user, + is_deleted=False, + ).first() + if not batch: + raise Http404("材料包批次不存在。") + exports = ExportedSummaryFile.objects.filter( + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=batch.pk, + ).order_by("-export_type", "id") + sorted_exports = sorted(exports, key=lambda item: 0 if item.export_type == ExportedSummaryFile.ExportType.ZIP else 1) + return JsonResponse( + { + "batch": { + "id": batch.pk, + "workflow_type": WORKFLOW_TYPE, + "batch_no": batch.batch_no, + "status": batch.status, + "product_name": batch.product_name, + "risk_summary_text": _risk_summary_text(batch), + "error_message": batch.error_message, + }, + "nodes": [ + { + "node_code": node.node_code, + "node_name": node.node_name, + "status": node.status, + "progress": node.progress, + "message": node.message, + } + for node in WorkflowNodeRun.objects.filter( + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=batch.pk, + ).order_by("id") + ], + "exports": [ + { + "id": export.pk, + "export_type": export.export_type, + "export_category": export.export_category, + "file_name": export.file_name, + "download_url": f"/api/review-agent/file-summary/exports/{export.pk}/download/", + } + for export in sorted_exports + ], + "failed_files": [item for item in batch.generated_files if item.get("status") == "failed"], + "notifications": [ + { + "id": item.pk, + "channel": item.channel, + "send_status": item.send_status, + "status_label": "通知已记录" if item.send_status == "success" else item.send_status, + "error_message": item.error_message, + } + for item in batch.notifications.filter(is_deleted=False).order_by("-created_at", "-id") + ], + } + ) + + +def _risk_summary_text(batch: RegulatoryInfoPackageBatch) -> str: + parts = [] + if batch.missing_fields: + parts.append(f"缺失字段 {len(batch.missing_fields)}") + if batch.llm_only_fields: + parts.append(f"LLM-only {len(batch.llm_only_fields)}") + if batch.conflict_fields: + parts.append(f"冲突字段 {len(batch.conflict_fields)}") + if batch.risk_notes: + parts.append(f"提示 {len(batch.risk_notes)}") + return " · ".join(parts) diff --git a/review_agent/regulatory_info_package/workflow.py b/review_agent/regulatory_info_package/workflow.py new file mode 100644 index 0000000..37250ba --- /dev/null +++ b/review_agent/regulatory_info_package/workflow.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +import logging +from threading import Thread +from uuid import uuid4 + +from django.conf import settings +from django.db import transaction +from django.utils import timezone + +from review_agent.file_summary.paths import resolve_storage_path +from review_agent.models import ( + Conversation, + ExportedSummaryFile, + Message, + RegulatoryInfoPackageArtifact, + RegulatoryInfoPackageBatch, + RegulatoryInfoPackageNotificationRecord, + WorkflowNodeRun, +) +from review_agent.regulatory_info_package.constants import ( + DEFAULT_ZIP_NAME, + REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS, + WORKFLOW_TYPE, +) +from review_agent.regulatory_info_package.events import record_event +from review_agent.regulatory_info_package.services.template_config import ( + compute_config_hash, + load_template_config, + validate_template_config, +) +from review_agent.regulatory_info_package.services.field_extract import run_parallel_extract, save_field_extract_result +from review_agent.regulatory_info_package.services.field_merge import merge_fields, save_merged_fields +from review_agent.regulatory_info_package.services.instruction_extract import parse_instruction_docx, save_instruction_extract_json +from review_agent.regulatory_info_package.services.package_generate import generate_package_documents +from review_agent.regulatory_info_package.services.summary import build_assistant_summary +from review_agent.regulatory_info_package.services.traceability_export import save_traceability_exports +from review_agent.regulatory_info_package.services.zip_export import create_zip_package +from review_agent.regulatory_info_package.schemas import GeneratedFileResult, InstructionExtractResult, MergedField +from review_agent.regulatory_info_package.storage import build_batch_work_dir +from review_agent.regulatory_info_package.storage import create_artifact_for_file, ensure_batch_subdir + + +logger = logging.getLogger("review_agent.regulatory_info_package.workflow") + + +def build_batch_no() -> str: + return f"RIP-{timezone.localtime().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6]}" + + +@transaction.atomic +def create_regulatory_info_package_batch( + *, + conversation: Conversation, + user, + trigger_message: Message | None = None, + source_attachment=None, + source_summary_batch=None, + source_summary_item_id: int | None = None, + source_file_name: str = "", + source_storage_path: str = "", + existing_batch: RegulatoryInfoPackageBatch | None = None, +) -> RegulatoryInfoPackageBatch: + batch = existing_batch + if batch is None: + batch_no = build_batch_no() + work_dir = build_batch_work_dir(batch_no=batch_no) + work_dir.mkdir(parents=True, exist_ok=True) + batch = RegulatoryInfoPackageBatch.objects.create( + conversation=conversation, + user=user, + trigger_message=trigger_message, + source_attachment=source_attachment, + source_summary_batch=source_summary_batch, + source_summary_item_id=source_summary_item_id, + source_file_name=source_file_name or getattr(source_attachment, "original_name", ""), + source_storage_path=source_storage_path or getattr(source_attachment, "storage_path", ""), + batch_no=batch_no, + output_zip_name=DEFAULT_ZIP_NAME, + work_dir=str(work_dir), + ) + for code, name, group in REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS: + WorkflowNodeRun.objects.get_or_create( + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=batch.pk, + node_code=code, + defaults={ + "node_group": group, + "node_name": name, + }, + ) + record_event(batch, "workflow_created", {"batch_id": batch.pk, "batch_no": batch.batch_no}) + return batch + + +class RegulatoryInfoPackageWorkflowExecutor: + """Runs the Chapter 1 regulatory information package workflow.""" + + def __init__(self, batch: RegulatoryInfoPackageBatch): + self.batch = batch + self.template_config: dict = {} + self.instruction: InstructionExtractResult | None = None + self.extract_payload: dict = {} + self.merged_fields: dict[str, MergedField] = {} + self.merge_summary: dict[str, list[dict]] = {} + self.generation_results: list[GeneratedFileResult] = [] + self.exports: list[ExportedSummaryFile] = [] + + def run(self) -> None: + logger.info("监管信息材料包工作流开始 batch_no=%s batch_id=%s", self.batch.batch_no, self.batch.pk) + self.batch.status = RegulatoryInfoPackageBatch.Status.RUNNING + self.batch.started_at = timezone.now() + self.batch.save(update_fields=["status", "started_at"]) + record_event(self.batch, "workflow_started", {"batch_id": self.batch.pk}) + try: + for node in self._nodes(): + if node.status in {WorkflowNodeRun.Status.SUCCESS, WorkflowNodeRun.Status.SKIPPED}: + continue + self._run_node(node) + except Exception as exc: + logger.exception("Regulatory info package workflow failed", extra={"batch_id": self.batch.pk}) + self.batch.status = RegulatoryInfoPackageBatch.Status.FAILED + self.batch.error_message = str(exc) + self.batch.finished_at = timezone.now() + self.batch.save(update_fields=["status", "error_message", "finished_at"]) + record_event(self.batch, "workflow_failed", {"message": str(exc)}) + return + self.batch.status = RegulatoryInfoPackageBatch.Status.SUCCESS + self.batch.finished_at = timezone.now() + self.batch.save(update_fields=["status", "finished_at"]) + self._append_completion_message() + record_event(self.batch, "workflow_completed", {"batch_id": self.batch.pk}) + + def _nodes(self): + return WorkflowNodeRun.objects.filter( + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=self.batch.pk, + ).order_by("id") + + def _run_node(self, node: WorkflowNodeRun) -> None: + node.status = WorkflowNodeRun.Status.RUNNING + node.progress = 10 + node.started_at = timezone.now() + node.message = f"{node.node_name}处理中" + node.save(update_fields=["status", "progress", "started_at", "message"]) + record_event(self.batch, "node_progress", {"node_code": node.node_code, "status": node.status}) + self._execute_node(node) + node.status = WorkflowNodeRun.Status.SUCCESS + node.progress = 100 + node.finished_at = timezone.now() + node.message = f"{node.node_name}完成" + node.save(update_fields=["status", "progress", "finished_at", "message"]) + record_event(self.batch, "node_progress", {"node_code": node.node_code, "status": node.status}) + + def _execute_node(self, node: WorkflowNodeRun) -> None: + if node.node_code == "prepare": + self.template_config = load_template_config() + errors = validate_template_config(self.template_config) + if errors: + raise ValueError(";".join(errors)) + self.batch.template_config_version = str(self.template_config.get("version") or "") + self.batch.template_config_hash = compute_config_hash() + self.batch.save(update_fields=["template_config_version", "template_config_hash"]) + return + if node.node_code == "template_copy": + return + if node.node_code == "text_extract": + if not self.batch.source_storage_path: + self.instruction = None + return + path = resolve_storage_path(self.batch.source_storage_path) + self.instruction = parse_instruction_docx(path) + json_path = ensure_batch_subdir(self.batch, "logs") / "instruction_extract.json" + save_instruction_extract_json(json_path, self.instruction) + create_artifact_for_file( + self.batch, + path=json_path, + artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.INSTRUCTION_EXTRACT, + file_format=RegulatoryInfoPackageArtifact.FileFormat.JSON, + created_by_node=node.node_code, + ) + return + if node.node_code == "field_extract": + if not self.instruction: + self.extract_payload = {"regex_results": {}, "llm_results": {}, "llm_error": ""} + return + self.extract_payload = run_parallel_extract(self.instruction, llm_extract_func=lambda _instruction: {}) + json_path = ensure_batch_subdir(self.batch, "logs") / "field_extract_result.json" + save_field_extract_result(json_path, self.extract_payload) + create_artifact_for_file( + self.batch, + path=json_path, + artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.FIELD_EXTRACT_RESULT, + file_format=RegulatoryInfoPackageArtifact.FileFormat.JSON, + created_by_node=node.node_code, + ) + return + if node.node_code == "field_merge": + self.merged_fields, self.merge_summary = merge_fields( + self.extract_payload.get("regex_results") or {}, + self.extract_payload.get("llm_results") or {}, + ) + product = self.merged_fields.get("product_name") + if product and product.value and product.value != "/": + self.batch.product_name = product.value + self.batch.missing_fields = self.merge_summary.get("missing_fields", []) + self.batch.llm_only_fields = self.merge_summary.get("llm_only_fields", []) + self.batch.conflict_fields = self.merge_summary.get("conflict_fields", []) + self.batch.save(update_fields=["product_name", "missing_fields", "llm_only_fields", "conflict_fields"]) + json_path = ensure_batch_subdir(self.batch, "logs") / "merged_fields.json" + save_merged_fields(json_path, self.merged_fields, self.merge_summary) + create_artifact_for_file( + self.batch, + path=json_path, + artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.MERGED_FIELDS, + file_format=RegulatoryInfoPackageArtifact.FileFormat.JSON, + created_by_node=node.node_code, + ) + return + if node.node_code == "generate_docs": + self.generation_results = generate_package_documents(self.batch, self.template_config, self.merged_fields) + generated_files = [] + for result in self.generation_results: + if result.path: + artifact = create_artifact_for_file( + self.batch, + path=result.path, + artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.GENERATED_DOCUMENT, + file_format=result.actual_format, + name=result.template_code, + metadata=result.__dict__, + created_by_node=node.node_code, + ) + result.artifact_id = artifact.pk + if result.status in {"success", "fallback_success"}: + export = self._create_export( + path=result.path, + export_type=ExportedSummaryFile.ExportType.WORD, + export_category="generated_document", + ) + result.export_id = export.pk + self.exports.append(export) + generated_files.append(result.__dict__) + self.batch.generated_files = generated_files + self.batch.save(update_fields=["generated_files"]) + return + if node.node_code == "highlight_review_items": + return + if node.node_code == "trace_export": + excel_path, json_path = save_traceability_exports(self.batch.work_dir, self.merged_fields) + create_artifact_for_file( + self.batch, + path=json_path, + artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.TRACEABILITY, + file_format=RegulatoryInfoPackageArtifact.FileFormat.JSON, + created_by_node=node.node_code, + ) + artifact = create_artifact_for_file( + self.batch, + path=excel_path, + artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.TRACEABILITY, + file_format=RegulatoryInfoPackageArtifact.FileFormat.EXCEL, + created_by_node=node.node_code, + ) + export = self._create_export( + path=str(excel_path), + export_type=ExportedSummaryFile.ExportType.EXCEL, + export_category="traceability", + ) + self.exports.append(export) + artifact.metadata = {"export_id": export.pk} + artifact.save(update_fields=["metadata"]) + return + if node.node_code == "zip_export": + zip_path = create_zip_package(self.batch.work_dir, self.generation_results, self.batch.output_zip_name) + artifact = create_artifact_for_file( + self.batch, + path=zip_path, + artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.ZIP_PACKAGE, + file_format=RegulatoryInfoPackageArtifact.FileFormat.ZIP, + created_by_node=node.node_code, + ) + export = self._create_export( + path=str(zip_path), + export_type=ExportedSummaryFile.ExportType.ZIP, + export_category="regulatory_info_package", + ) + self.exports.insert(0, export) + artifact.metadata = {"export_id": export.pk} + artifact.save(update_fields=["metadata"]) + return + if node.node_code == "notify": + RegulatoryInfoPackageNotificationRecord.objects.create( + batch=self.batch, + recipient=self.batch.user, + export_ids=[export.pk for export in self.exports], + message_summary=build_assistant_summary( + batch_no=self.batch.batch_no, + exports=[ + { + "file_name": export.file_name, + "download_url": f"/api/review-agent/file-summary/exports/{export.pk}/download/", + "export_type": export.export_type, + } + for export in self.exports + ], + failed_files=[item for item in self.batch.generated_files if item.get("status") == "failed"], + ), + send_status=RegulatoryInfoPackageNotificationRecord.SendStatus.SUCCESS, + ) + return + + def _append_completion_message(self) -> None: + if ( + Message.objects.filter( + conversation=self.batch.conversation, + role=Message.Role.ASSISTANT, + content__contains=self.batch.batch_no, + ) + .filter(content__contains=self.batch.output_zip_name) + .exists() + ): + return + exports = list( + ExportedSummaryFile.objects.filter( + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=self.batch.pk, + ) + ) + exports = sorted(exports, key=lambda export: 0 if export.export_type == ExportedSummaryFile.ExportType.ZIP else 1) + content = build_assistant_summary( + batch_no=self.batch.batch_no, + exports=[ + { + "file_name": export.file_name, + "download_url": f"/api/review-agent/file-summary/exports/{export.pk}/download/", + "export_type": export.export_type, + } + for export in exports + ], + failed_files=[item for item in self.batch.generated_files if item.get("status") == "failed"], + ) + Message.objects.create( + conversation=self.batch.conversation, + role=Message.Role.ASSISTANT, + content=content, + ) + + def _create_export(self, *, path: str, export_type: str, export_category: str) -> ExportedSummaryFile: + from pathlib import Path + + resolved = Path(path) + return ExportedSummaryFile.objects.create( + batch=None, + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=self.batch.pk, + export_category=export_category, + export_type=export_type, + file_name=resolved.name, + storage_path=str(resolved), + ) + + +def start_regulatory_info_package_workflow( + batch: RegulatoryInfoPackageBatch, + *, + async_run: bool | None = None, +) -> None: + if async_run is None: + async_run = getattr(settings, "REGULATORY_INFO_PACKAGE_ASYNC", True) + executor = RegulatoryInfoPackageWorkflowExecutor(batch) + if async_run: + Thread(target=executor.run, daemon=True).start() + else: + executor.run() diff --git a/review_agent/regulatory_review/__init__.py b/review_agent/regulatory_review/__init__.py new file mode 100644 index 0000000..a47f031 --- /dev/null +++ b/review_agent/regulatory_review/__init__.py @@ -0,0 +1 @@ +"""NMPA regulatory review workflow package.""" diff --git a/review_agent/regulatory_review/events.py b/review_agent/regulatory_review/events.py new file mode 100644 index 0000000..a752d36 --- /dev/null +++ b/review_agent/regulatory_review/events.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from review_agent.models import RegulatoryReviewBatch, WorkflowEvent + + +def record_event( + batch: RegulatoryReviewBatch, + event_type: str, + payload: dict | None = None, +) -> WorkflowEvent: + return WorkflowEvent.objects.create( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + conversation=batch.conversation, + event_type=event_type, + payload=payload or {}, + ) + + +def serialize_event(event: WorkflowEvent) -> dict[str, object]: + return { + "id": event.pk, + "event_type": event.event_type, + "payload": event.payload, + "created_at": event.created_at.isoformat(), + } diff --git a/review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml b/review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml new file mode 100644 index 0000000..909b63f --- /dev/null +++ b/review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml @@ -0,0 +1,503 @@ +code: nmpa_ivd_registration_v1 +name: NMPA IVD 注册资料附件 4 对齐规则 +rag_collection: nmpa_ivd_registration_v1 +source_material_dir: docs/0.原始材料 +attachment4_required_codes: + - "1" + - "1.1" + - "1.2" + - "1.3" + - "1.4" + - "1.5" + - "1.6" + - "1.7" + - "2" + - "2.1" + - "2.2" + - "2.3" + - "2.4" + - "2.5" + - "2.6" + - "3" + - "3.1" + - "3.2" + - "3.3" + - "3.4" + - "3.5" + - "3.6" + - "3.7" + - "3.8" + - "4" + - "4.1" + - "4.2" + - "5" + - "5.1" + - "5.2" + - "5.3" + - "5.4" + - "6" + - "6.1" + - "6.2" + - "6.3" + - "6.4" + - "6.5" + - "6.6" + - "6.7" + - "6.8" + - "6.9" + - "6.10" +requirements: + - code: attachment4_1_regulatory_info + rule_id: A4-1 + attachment4_code: "1" + title: 监管信息 + type: chapter + severity: high + category: completeness + file_keywords: [监管信息] + aliases: [监管资料] + suggestion: 请补充监管信息章节及其目录项。 + citation_query: 附件4 监管信息 体外诊断试剂 注册申报资料 + structure_required: true + - code: attachment4_1_1_toc + rule_id: A4-1.1 + attachment4_code: "1.1" + title: 章节目录 + type: directory + severity: medium + category: completeness + file_keywords: [章节目录, 目录] + aliases: [监管信息目录] + suggestion: 请补充监管信息章节目录。 + citation_query: 附件4 监管信息 章节目录 + - code: attachment4_1_2_application_form + rule_id: A4-1.2 + attachment4_code: "1.2" + title: 申请表 + type: required + severity: blocking + category: completeness + file_keywords: [申请表, 注册申请表] + aliases: [医疗器械注册申请表] + suggestion: 请补充注册申请表并核对注册类型、管理类别和分类编码。 + citation_query: 附件4 监管信息 申请表 + - code: attachment4_1_3_terms + rule_id: A4-1.3 + attachment4_code: "1.3" + title: 术语/缩写词列表 + type: recommended + severity: medium + category: completeness + file_keywords: [术语, 缩写词, 缩略语] + suggestion: 请补充术语和缩写词列表。 + citation_query: 附件4 术语 缩写词列表 + - code: attachment4_1_4_product_list + rule_id: A4-1.4 + attachment4_code: "1.4" + title: 产品列表 + type: required + severity: high + category: completeness + file_keywords: [产品列表, 产品清单] + suggestion: 请补充申报产品列表。 + citation_query: 附件4 产品列表 + - code: attachment4_1_5_related_files + rule_id: A4-1.5 + attachment4_code: "1.5" + title: 关联文件 + type: conditional + severity: medium + category: completeness + file_keywords: [关联文件, 关联注册, 引用文件] + suggestion: 如存在关联注册或引用资料,请补充关联文件说明。 + citation_query: 附件4 关联文件 + - code: attachment4_1_6_pre_submission + rule_id: A4-1.6 + attachment4_code: "1.6" + title: 申报前与监管机构的联系情况和沟通记录 + type: conditional + severity: medium + category: completeness + file_keywords: [沟通记录, 监管机构, 申报前] + suggestion: 如有申报前沟通,请补充沟通记录;如无,请说明不适用。 + citation_query: 附件4 申报前 监管机构 沟通记录 + - code: attachment4_1_7_declaration + rule_id: A4-1.7 + attachment4_code: "1.7" + title: 符合性声明 + type: required + severity: blocking + category: completeness + file_keywords: [符合性声明, 声明] + suggestion: 请补充符合性声明。 + citation_query: 附件4 符合性声明 + - code: attachment4_2_summary + rule_id: A4-2 + attachment4_code: "2" + title: 综述资料 + type: chapter + severity: high + category: completeness + file_keywords: [综述资料] + suggestion: 请补充综述资料章节。 + citation_query: 附件4 综述资料 + structure_required: true + - code: attachment4_2_1_toc + rule_id: A4-2.1 + attachment4_code: "2.1" + title: 章节目录 + type: directory + severity: medium + category: completeness + file_keywords: [章节目录, 综述资料目录] + suggestion: 请补充综述资料章节目录。 + citation_query: 附件4 综述资料 章节目录 + - code: attachment4_2_2_overview + rule_id: A4-2.2 + attachment4_code: "2.2" + title: 概述 + type: required + severity: high + category: completeness + file_keywords: [概述] + suggestion: 请补充产品概述。 + citation_query: 附件4 概述 + - code: attachment4_2_3_product_description + rule_id: A4-2.3 + attachment4_code: "2.3" + title: 产品描述 + type: required + severity: high + category: completeness + file_keywords: [产品描述] + suggestion: 请补充产品描述。 + citation_query: 附件4 产品描述 + - code: attachment4_2_4_intended_use + rule_id: A4-2.4 + attachment4_code: "2.4" + title: 预期用途 + type: required + severity: high + category: completeness + file_keywords: [预期用途] + suggestion: 请补充预期用途资料。 + citation_query: 附件4 预期用途 + - code: attachment4_2_5_marketing_history + rule_id: A4-2.5 + attachment4_code: "2.5" + title: 申报产品上市历史 + type: conditional + severity: medium + category: completeness + file_keywords: [上市历史] + suggestion: 如产品已有上市历史,请补充相关说明;如无,请说明不适用。 + citation_query: 附件4 上市历史 + - code: attachment4_2_6_other_summary + rule_id: A4-2.6 + attachment4_code: "2.6" + title: 其他需说明的内容 + type: conditional + severity: medium + category: completeness + file_keywords: [其他需说明, 其他说明] + suggestion: 请补充其他需说明内容或不适用说明。 + citation_query: 附件4 其他需说明 + - code: attachment4_3_nonclinical + rule_id: A4-3 + attachment4_code: "3" + title: 非临床资料 + type: chapter + severity: high + category: completeness + file_keywords: [非临床资料] + suggestion: 请补充非临床资料章节。 + citation_query: 附件4 非临床资料 + structure_required: true + - code: attachment4_3_1_toc + rule_id: A4-3.1 + attachment4_code: "3.1" + title: 章节目录 + type: directory + severity: medium + category: completeness + file_keywords: [章节目录, 非临床资料目录] + suggestion: 请补充非临床资料章节目录。 + citation_query: 附件4 非临床资料 章节目录 + - code: attachment4_3_2_risk_management + rule_id: A4-3.2 + attachment4_code: "3.2" + title: 产品风险管理资料 + type: required + severity: high + category: completeness + file_keywords: [产品风险管理, 风险管理资料] + suggestion: 请补充产品风险管理资料。 + citation_query: 附件4 产品风险管理资料 + - code: essential_principles_checklist + rule_id: A4-3.3 + attachment4_code: "3.3" + title: 体外诊断试剂安全和性能基本原则清单 + type: recommended + severity: medium + category: completeness + file_keywords: [安全和性能基本原则, 基本原则清单] + aliases: [安全和性能基本原则清单] + suggestion: 建议补充安全和性能基本原则清单,便于审评追溯。 + citation_query: 附件4 安全和性能基本原则清单 + - code: product_technical_requirements + rule_id: A4-3.4 + attachment4_code: "3.4" + title: 产品技术要求及检验报告 + type: required + severity: blocking + category: completeness + file_keywords: [产品技术要求, 注册检验报告, 检验报告] + aliases: [产品技术要求, 注册检验报告] + required_sections: [产品技术要求, 检验报告] + suggestion: 请补充产品技术要求及注册检验报告,并确认二者覆盖型号一致。 + citation_query: 附件4 产品技术要求 检验报告 + - code: registration_test_report + rule_id: A4-3.4-R + attachment4_code: "3.4" + title: 注册检验报告 + type: required + severity: blocking + category: completeness + file_keywords: [注册检验报告, 检验报告] + suggestion: 请补充注册检验报告并复核报告覆盖的产品型号。 + citation_query: 附件4 注册检验报告 + - code: attachment4_3_5_analytical_performance + rule_id: A4-3.5 + attachment4_code: "3.5" + title: 分析性能研究 + type: required + severity: high + category: completeness + file_keywords: [分析性能研究, 分析性能] + suggestion: 请补充分析性能研究资料。 + citation_query: 附件4 分析性能研究 + - code: attachment4_3_6_stability + rule_id: A4-3.6 + attachment4_code: "3.6" + title: 稳定性研究 + type: required + severity: high + category: completeness + file_keywords: [稳定性研究, 稳定性] + suggestion: 请补充稳定性研究资料。 + citation_query: 附件4 稳定性研究 + - code: attachment4_3_7_reference_interval + rule_id: A4-3.7 + attachment4_code: "3.7" + title: 阳性判断值或参考区间研究 + type: required + severity: high + category: completeness + file_keywords: [阳性判断值, 参考区间] + suggestion: 请补充阳性判断值或参考区间研究资料。 + citation_query: 附件4 阳性判断值 参考区间 + - code: attachment4_3_8_other_nonclinical + rule_id: A4-3.8 + attachment4_code: "3.8" + title: 其他资料 + type: conditional + severity: medium + category: completeness + file_keywords: [其他资料] + suggestion: 请补充非临床其他资料或不适用说明。 + citation_query: 附件4 非临床 其他资料 + - code: attachment4_4_clinical_evaluation + rule_id: A4-4 + attachment4_code: "4" + title: 临床评价资料 + type: chapter + severity: high + category: completeness + file_keywords: [临床评价资料, 临床资料] + suggestion: 请补充临床评价资料章节。 + citation_query: 附件4 临床评价资料 + structure_required: true + - code: attachment4_4_1_toc + rule_id: A4-4.1 + attachment4_code: "4.1" + title: 章节目录 + type: directory + severity: medium + category: completeness + file_keywords: [章节目录, 临床评价资料目录] + suggestion: 请补充临床评价资料章节目录。 + citation_query: 附件4 临床评价资料 章节目录 + - code: clinical_evaluation + rule_id: A4-4.2 + attachment4_code: "4.2" + title: 临床评价资料 + type: conditional + severity: high + category: completeness + file_keywords: [临床评价, 临床试验, 免临床, 同品种比对] + suggestion: 请根据适用情形补充临床评价资料或说明豁免依据。 + citation_query: 附件4 临床评价资料 注册申报 + - code: attachment4_5_ifu_label + rule_id: A4-5 + attachment4_code: "5" + title: 产品说明书和标签样稿 + type: chapter + severity: high + category: completeness + file_keywords: [产品说明书和标签样稿, 说明书, 标签样稿] + suggestion: 请补充产品说明书和标签样稿章节。 + citation_query: 附件4 产品说明书 标签样稿 + structure_required: true + - code: attachment4_5_1_toc + rule_id: A4-5.1 + attachment4_code: "5.1" + title: 章节目录 + type: directory + severity: medium + category: completeness + file_keywords: [章节目录, 说明书目录, 标签目录] + suggestion: 请补充产品说明书和标签样稿章节目录。 + citation_query: 附件4 说明书 标签 章节目录 + - code: instructions_for_use + rule_id: A4-5.2 + attachment4_code: "5.2" + title: 产品说明书 + type: required + severity: high + category: completeness + file_keywords: [说明书, 产品说明书, 使用说明] + aliases: [说明书] + required_sections: [储存条件, 有效期, 样本要求] + suggestion: 请补充说明书并核对储存条件、有效期和样本要求章节。 + citation_query: 附件4 产品说明书 储存条件 有效期 样本要求 + - code: attachment4_5_3_label + rule_id: A4-5.3 + attachment4_code: "5.3" + title: 标签样稿 + type: required + severity: high + category: completeness + file_keywords: [标签样稿, 标签] + suggestion: 请补充标签样稿。 + citation_query: 附件4 标签样稿 + - code: attachment4_5_4_other_ifu + rule_id: A4-5.4 + attachment4_code: "5.4" + title: 其他资料 + type: conditional + severity: medium + category: completeness + file_keywords: [其他资料] + suggestion: 请补充说明书和标签相关其他资料或不适用说明。 + citation_query: 附件4 说明书 标签 其他资料 + - code: attachment4_6_quality_system + rule_id: A4-6 + attachment4_code: "6" + title: 质量管理体系文件 + type: chapter + severity: high + category: completeness + file_keywords: [质量管理体系文件, 质量体系, 质量管理体系] + suggestion: 请补充质量管理体系文件章节。 + citation_query: 附件4 质量管理体系文件 + structure_required: true + - code: attachment4_6_1_overview + rule_id: A4-6.1 + attachment4_code: "6.1" + title: 综述 + type: required + severity: high + category: completeness + file_keywords: [综述] + suggestion: 请补充质量管理体系综述。 + citation_query: 附件4 质量管理体系 综述 + - code: attachment4_6_2_toc + rule_id: A4-6.2 + attachment4_code: "6.2" + title: 章节目录 + type: directory + severity: medium + category: completeness + file_keywords: [章节目录, 质量管理体系目录] + suggestion: 请补充质量管理体系文件章节目录。 + citation_query: 附件4 质量管理体系 章节目录 + - code: attachment4_6_3_manufacturing + rule_id: A4-6.3 + attachment4_code: "6.3" + title: 生产制造信息 + type: required + severity: high + category: completeness + file_keywords: [生产制造信息, 生产制造] + suggestion: 请补充生产制造信息。 + citation_query: 附件4 生产制造信息 + - code: attachment4_6_4_qms_procedure + rule_id: A4-6.4 + attachment4_code: "6.4" + title: 质量管理体系程序 + type: required + severity: high + category: completeness + file_keywords: [质量管理体系程序, 质量体系程序] + suggestion: 请补充质量管理体系程序。 + citation_query: 附件4 质量管理体系程序 + - code: attachment4_6_5_management + rule_id: A4-6.5 + attachment4_code: "6.5" + title: 管理职责程序 + type: required + severity: high + category: completeness + file_keywords: [管理职责程序, 管理职责] + suggestion: 请补充管理职责程序。 + citation_query: 附件4 管理职责程序 + - code: attachment4_6_6_resource + rule_id: A4-6.6 + attachment4_code: "6.6" + title: 资源管理程序 + type: required + severity: high + category: completeness + file_keywords: [资源管理程序, 资源管理] + suggestion: 请补充资源管理程序。 + citation_query: 附件4 资源管理程序 + - code: attachment4_6_7_realization + rule_id: A4-6.7 + attachment4_code: "6.7" + title: 产品实现程序 + type: required + severity: high + category: completeness + file_keywords: [产品实现程序, 产品实现] + suggestion: 请补充产品实现程序。 + citation_query: 附件4 产品实现程序 + - code: attachment4_6_8_measurement + rule_id: A4-6.8 + attachment4_code: "6.8" + title: 质量管理体系的测量/分析和改进程序 + type: required + severity: high + category: completeness + file_keywords: [测量, 分析和改进, 改进程序] + suggestion: 请补充质量管理体系测量、分析和改进程序。 + citation_query: 附件4 测量 分析 改进程序 + - code: attachment4_6_9_other_qms + rule_id: A4-6.9 + attachment4_code: "6.9" + title: 其他质量体系程序信息 + type: conditional + severity: medium + category: completeness + file_keywords: [其他质量体系程序, 其他质量体系] + suggestion: 请补充其他质量体系程序信息或不适用说明。 + citation_query: 附件4 其他质量体系程序信息 + - code: attachment4_6_10_qms_audit + rule_id: A4-6.10 + attachment4_code: "6.10" + title: 质量管理体系核查文件 + type: required + severity: high + category: completeness + file_keywords: [质量管理体系核查文件, 体系核查文件, 核查文件] + suggestion: 请补充质量管理体系核查文件。 + citation_query: 附件4 质量管理体系核查文件 diff --git a/review_agent/regulatory_review/schemas.py b/review_agent/regulatory_review/schemas.py new file mode 100644 index 0000000..394b593 --- /dev/null +++ b/review_agent/regulatory_review/schemas.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field + + +@dataclass(frozen=True) +class Finding: + rule_code: str + category: str + severity: str + title: str + detail: str = "" + suggestion: str = "" + evidence: dict[str, object] = field(default_factory=dict) + citations: list[dict[str, object]] = field(default_factory=list) + + def to_dict(self) -> dict[str, object]: + return asdict(self) diff --git a/review_agent/regulatory_review/services/__init__.py b/review_agent/regulatory_review/services/__init__.py new file mode 100644 index 0000000..8c2d48e --- /dev/null +++ b/review_agent/regulatory_review/services/__init__.py @@ -0,0 +1 @@ +"""Services for NMPA regulatory review.""" diff --git a/review_agent/regulatory_review/services/completeness_check.py b/review_agent/regulatory_review/services/completeness_check.py new file mode 100644 index 0000000..47e317f --- /dev/null +++ b/review_agent/regulatory_review/services/completeness_check.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from collections.abc import Callable + +from review_agent.models import FileSummaryBatch +from review_agent.regulatory_review.schemas import Finding + + +def run_completeness_check( + batch: FileSummaryBatch, + rule_set: dict, + progress_callback: Callable[[dict[str, object]], None] | None = None, +) -> list[Finding]: + items = list(batch.items.order_by("file_index")) + findings: list[Finding] = [] + requirements = [ + requirement + for requirement in rule_set.get("requirements", []) + if requirement.get("type") in {"required", "conditional", "recommended", "chapter", "directory"} + ] + total = len(requirements) + for index, requirement in enumerate(requirements, start=1): + if requirement.get("type") not in {"required", "conditional", "recommended", "chapter", "directory"}: + continue + matched = [ + item + for item in items + if _matches_item( + item.file_name, + item.relative_path, + item.directory_level, + [*requirement.get("file_keywords", []), *requirement.get("aliases", [])], + ) + ] + if not matched: + findings.append( + Finding( + rule_code=requirement["code"], + category=requirement.get("category", "completeness"), + severity=requirement.get("severity", "medium"), + title=f"缺少{_numbered_title(requirement)}", + detail=f"当前文件汇总批次未发现{_numbered_title(requirement)}。", + suggestion=requirement.get("suggestion", ""), + evidence={ + "requirement_type": requirement.get("type"), + "matched_files": [], + "searched_keywords": requirement.get("file_keywords", []), + "searched_fields": ["file_name", "relative_path", "directory_level"], + }, + ) + ) + if progress_callback: + progress_callback( + { + "processed": index, + "total": total, + "label": _numbered_title(requirement), + "finding_count": len(findings), + } + ) + return findings + + +def _matches_item(file_name: str, relative_path: str, directory_level: str, keywords: list[str]) -> bool: + haystack = f"{file_name} {relative_path} {directory_level}".lower() + return any(str(keyword).lower() in haystack for keyword in keywords) + + +def _numbered_title(requirement: dict) -> str: + attachment4_code = requirement.get("attachment4_code") + if not attachment4_code: + return requirement["title"] + return f"{attachment4_code}{requirement['title']}" diff --git a/review_agent/regulatory_review/services/consistency_check.py b/review_agent/regulatory_review/services/consistency_check.py new file mode 100644 index 0000000..19193aa --- /dev/null +++ b/review_agent/regulatory_review/services/consistency_check.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import re +from collections import defaultdict +from collections.abc import Callable + +from review_agent.regulatory_review.schemas import Finding + + +FIELDS = { + "产品名称": r"产品名称[::]\s*([^\n\r]+)", + "型号规格": r"型号规格[::]\s*([^\n\r]+)", + "预期用途": r"预期用途[::]\s*([^\n\r]+)", + "管理类别": r"管理类别[::]\s*([^\n\r]+)", + "分类编码": r"分类编码[::]\s*([^\n\r]+)", + "注册类型": r"注册类型[::]\s*([^\n\r]+)", + "临床评价路径": r"临床评价路径[::]\s*([^\n\r]+)", +} + + +def run_consistency_check( + document_texts: dict[str, str], + progress_callback: Callable[[dict[str, object]], None] | None = None, +) -> list[Finding]: + findings: list[Finding] = [] + fields = list(FIELDS.items()) + total = len(fields) + for index, (label, pattern) in enumerate(fields, start=1): + values: dict[str, list[str]] = defaultdict(list) + for file_name, text in document_texts.items(): + match = re.search(pattern, text) + if match: + values[_normalize(match.group(1))].append(file_name) + if len(values) > 1: + findings.append( + Finding( + rule_code=f"consistency:{label}", + category="consistency", + severity="high", + title=f"{label}在不同文件中不一致", + detail=f"发现 {len(values)} 个不同的{label}取值。", + suggestion=f"请统一各注册资料中的{label}。", + evidence={"field": label, "values": dict(values)}, + ) + ) + if progress_callback: + progress_callback( + { + "processed": index, + "total": total, + "label": label, + "finding_count": len(findings), + } + ) + return findings + + +def _normalize(value: str) -> str: + return " ".join(value.strip().split()) diff --git a/review_agent/regulatory_review/services/export.py b/review_agent/regulatory_review/services/export.py new file mode 100644 index 0000000..9a84eb8 --- /dev/null +++ b/review_agent/regulatory_review/services/export.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from django.conf import settings +from openpyxl import Workbook + +from review_agent.models import ExportedSummaryFile, RegulatoryIssue, RegulatoryReviewBatch + + +SEVERITY_LABELS = { + "blocking": "阻断项", + "high": "高风险", + "medium": "中风险", + "low": "低风险", + "info": "提示", +} + + +def export_review_results(batch: RegulatoryReviewBatch) -> list[ExportedSummaryFile]: + root = Path(batch.work_dir) if batch.work_dir else Path(settings.MEDIA_ROOT) / "regulatory_review" / "work" / batch.batch_no + export_dir = root / "exports" + export_dir.mkdir(parents=True, exist_ok=True) + + markdown = _create_export( + batch, + export_dir / f"{batch.batch_no}-regulatory-review.md", + ExportedSummaryFile.ExportType.MARKDOWN, + "markdown_report", + build_markdown_report(batch), + ) + excel = _create_excel_export(batch, export_dir / f"{batch.batch_no}-regulatory-issues.xlsx") + result_json = _create_export( + batch, + export_dir / f"{batch.batch_no}-regulatory-result.json", + ExportedSummaryFile.ExportType.JSON, + "result_package", + json.dumps(build_result_payload(batch), ensure_ascii=False, indent=2), + ) + return [markdown, excel, result_json] + + +def build_markdown_report(batch: RegulatoryReviewBatch) -> str: + lines = [ + "# NMPA 注册资料法规核查报告", + "", + f"批次号:{batch.batch_no}", + ] + regenerated_from = (batch.condition_json or {}).get("regenerated_from") + if regenerated_from: + lines.extend( + [ + "", + "## 复核来源", + "", + f"- 来源法规核查批次:{regenerated_from.get('batch_no')}", + f"- 来源文件汇总批次:{regenerated_from.get('file_summary_batch_no')}", + ] + ) + lines.extend(["", "## 风险汇总", "", "| 风险等级 | 数量 |", "| --- | --- |"]) + summary = batch.risk_summary or {} + for severity, label in SEVERITY_LABELS.items(): + lines.append(f"| {label} | {summary.get(severity, 0)} |") + lines.extend(["", "## 问题清单", "", "| 等级 | 问题 | 状态 | 建议 |", "| --- | --- | --- | --- |"]) + for issue in batch.issues.order_by("id"): + lines.append( + f"| {SEVERITY_LABELS.get(issue.severity, issue.severity)} | {issue.title} | {issue.status} | {issue.suggestion or '-'} |" + ) + review_records = _review_records(batch) + if review_records: + lines.extend(["", "## 复核记录", "", "| 补充批次 | 问题数 | 通过数 | 未通过数 |", "| --- | --- | --- | --- |"]) + for record in review_records: + items = record.get("items", []) + passed = sum(1 for item in items if item.get("status") == RegulatoryIssue.Status.REVIEW_PASSED) + failed = sum(1 for item in items if item.get("status") == RegulatoryIssue.Status.REVIEW_FAILED) + lines.append(f"| {record.get('file_summary_batch_no')} | {len(items)} | {passed} | {failed} |") + notifications = _notification_records(batch) + if notifications: + lines.extend(["", "## 通知记录", "", "| 渠道 | 对象 | 状态 | 问题 |", "| --- | --- | --- | --- |"]) + for record in notifications: + lines.append( + f"| {record['channel']} | {record['target'] or '-'} | {record['status']} | {record['payload'].get('title', '-')} |" + ) + return "\n".join(lines) + + +def build_result_payload(batch: RegulatoryReviewBatch) -> dict[str, object]: + return { + "batch_no": batch.batch_no, + "source_summary_batch": batch.source_summary_batch.batch_no, + "regenerated_from": (batch.condition_json or {}).get("regenerated_from"), + "risk_summary": batch.risk_summary, + "issues": [ + { + "severity": issue.severity, + "category": issue.category, + "rule_code": issue.rule_code, + "title": issue.title, + "detail": issue.detail, + "suggestion": issue.suggestion, + "status": issue.status, + "evidence": issue.evidence, + "citations": issue.citations, + } + for issue in batch.issues.order_by("id") + ], + "review_records": _review_records(batch), + "notifications": _notification_records(batch), + } + + +def build_assistant_summary(batch: RegulatoryReviewBatch, exports: list[ExportedSummaryFile]) -> str: + export_by_type = {export.export_type: export for export in exports} + lines = [ + "已完成 NMPA 注册资料法规核查。", + "", + "| 风险等级 | 数量 |", + "| --- | --- |", + ] + summary = batch.risk_summary or {} + for severity, label in SEVERITY_LABELS.items(): + if summary.get(severity, 0): + lines.append(f"| {label} | {summary[severity]} |") + lines.extend(["", "| 等级 | 问题 | 状态 | 建议 |", "| --- | --- | --- | --- |"]) + for issue in batch.issues.order_by("id")[:8]: + lines.append( + f"| {SEVERITY_LABELS.get(issue.severity, issue.severity)} | {issue.title} | {issue.status} | {issue.suggestion or '-'} |" + ) + lines.extend( + [ + "", + _download_link("下载 Markdown 核查报告", export_by_type.get(ExportedSummaryFile.ExportType.MARKDOWN)), + _download_link("下载 Excel 缺失清单", export_by_type.get(ExportedSummaryFile.ExportType.EXCEL)), + _download_link("下载 JSON 结果包", export_by_type.get(ExportedSummaryFile.ExportType.JSON)), + ] + ) + return "\n".join(line for line in lines if line is not None) + + +def _download_link(label: str, exported: ExportedSummaryFile | None) -> str | None: + if not exported: + return None + return f"[{label}](/api/review-agent/file-summary/exports/{exported.pk}/download/)" + + +def _create_export( + batch: RegulatoryReviewBatch, + path: Path, + export_type: str, + category: str, + content: str, +) -> ExportedSummaryFile: + path.write_text(content, encoding="utf-8") + return ExportedSummaryFile.objects.create( + batch=batch.source_summary_batch, + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + export_category=category, + export_type=export_type, + file_name=path.name, + storage_path=str(path), + ) + + +def _create_excel_export(batch: RegulatoryReviewBatch, path: Path) -> ExportedSummaryFile: + workbook = Workbook() + sheet = workbook.active + sheet.title = "法规问题清单" + sheet.append(["等级", "类别", "规则", "问题", "状态", "建议", "法规依据", "通知记录"]) + for issue in batch.issues.order_by("id"): + sheet.append( + [ + SEVERITY_LABELS.get(issue.severity, issue.severity), + issue.category, + issue.rule_code, + issue.title, + issue.status, + issue.suggestion, + "; ".join(str(item.get("source", "")) for item in issue.citations), + _notification_summary_for_issue(batch, issue.pk), + ] + ) + workbook.save(path) + return ExportedSummaryFile.objects.create( + batch=batch.source_summary_batch, + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + export_category="issue_checklist", + export_type=ExportedSummaryFile.ExportType.EXCEL, + file_name=path.name, + storage_path=str(path), + ) + + +def _review_records(batch: RegulatoryReviewBatch) -> list[dict[str, object]]: + records = [] + for artifact in batch.artifacts.filter(metadata__artifact="review_record").order_by("created_at", "id"): + try: + records.append(json.loads(Path(artifact.storage_path).read_text(encoding="utf-8"))) + except (OSError, json.JSONDecodeError): + continue + return records + + +def _notification_records(batch: RegulatoryReviewBatch) -> list[dict[str, object]]: + return [ + { + "channel": record.channel, + "target": record.target, + "status": record.status, + "payload": record.payload, + "sent_at": record.sent_at.isoformat() if record.sent_at else "", + } + for record in batch.notifications.order_by("created_at", "id") + ] + + +def _notification_summary_for_issue(batch: RegulatoryReviewBatch, issue_id: int) -> str: + records = [ + record + for record in batch.notifications.all() + if isinstance(record.payload, dict) and record.payload.get("issue_id") == issue_id + ] + return "; ".join(f"{record.channel}:{record.status}" for record in records) diff --git a/review_agent/regulatory_review/services/feishu_notifier.py b/review_agent/regulatory_review/services/feishu_notifier.py new file mode 100644 index 0000000..10cd4f8 --- /dev/null +++ b/review_agent/regulatory_review/services/feishu_notifier.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from django.utils import timezone + +from review_agent.models import RegulatoryNotificationRecord, RegulatoryReviewBatch + + +NOTIFIABLE_SEVERITIES = {"blocking", "high", "medium"} + + +def create_mock_notifications(batch: RegulatoryReviewBatch) -> list[RegulatoryNotificationRecord]: + records = [] + existing_issue_ids = { + item.get("issue_id") + for item in RegulatoryNotificationRecord.objects.filter(batch=batch, channel=RegulatoryNotificationRecord.Channel.MOCK).values_list( + "payload", flat=True + ) + if isinstance(item, dict) + } + for issue in batch.issues.order_by("id"): + if issue.severity not in NOTIFIABLE_SEVERITIES or issue.pk in existing_issue_ids: + continue + records.append( + RegulatoryNotificationRecord.objects.create( + batch=batch, + channel=RegulatoryNotificationRecord.Channel.MOCK, + target="法规整改负责人", + status=RegulatoryNotificationRecord.Status.SENT, + sent_at=timezone.now(), + payload={ + "issue_id": issue.pk, + "rule_code": issue.rule_code, + "severity": issue.severity, + "title": issue.title, + "suggestion": issue.suggestion, + }, + ) + ) + return records diff --git a/review_agent/regulatory_review/services/info_extract.py b/review_agent/regulatory_review/services/info_extract.py new file mode 100644 index 0000000..90d17f2 --- /dev/null +++ b/review_agent/regulatory_review/services/info_extract.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from django.conf import settings + +from review_agent.models import FileSummaryBatch, RegulatoryReviewBatch +from review_agent.regulatory_review.services.llm_review import review_condition_fields +from review_agent.regulatory_review.services.text_extract import extract_text + + +OPTION_FIELDS = { + "product_category": ["体外诊断试剂", "医疗器械", "其他"], + "registration_type": ["首次注册", "变更注册", "延续注册"], + "clinical_evaluation_path": ["临床试验", "免临床", "同品种比对", "待确认"], +} + + +def ensure_regulatory_condition_candidates(batch: RegulatoryReviewBatch) -> dict[str, dict[str, object]]: + condition_json = batch.condition_json or {} + candidates = condition_json.get("candidates") or {} + if batch.status != RegulatoryReviewBatch.Status.WAITING_USER or not _condition_candidates_incomplete(candidates): + return candidates + refreshed = detect_regulatory_condition_candidates(batch.source_summary_batch) + refreshed = _merge_condition_candidates(candidates, refreshed) + batch.condition_json = {**condition_json, "candidates": refreshed} + batch.save(update_fields=["condition_json"]) + return refreshed + + +def detect_regulatory_condition_candidates(summary_batch: FileSummaryBatch) -> dict[str, dict[str, object]]: + """Infers review-scope conditions from the summary batch and file names.""" + + corpus_parts = [summary_batch.product_name or ""] + field_candidates: dict[str, str] = {} + field_sources: dict[str, str] = {} + for item in summary_batch.items.order_by("file_index"): + corpus_parts.extend([item.directory_level, item.file_name, item.relative_path]) + review = _extract_item_fields(item) + extracted = review.get("selected_fields", {}) + sources = review.get("selected_sources", {}) + field_candidates.update({key: value for key, value in extracted.items() if value and key not in field_candidates}) + field_sources.update({key: value for key, value in sources.items() if value and key not in field_sources}) + corpus_parts.extend(extracted.values()) + if review.get("front_text"): + corpus_parts.append(str(review["front_text"])) + corpus = "\n".join(part for part in corpus_parts if part) + product_name = field_candidates.get("产品名称") or _safe_summary_product_name(summary_batch.product_name) + + return { + "product_category": { + "label": "产品类别", + "input_type": "select", + "options": OPTION_FIELDS["product_category"], + "suggested": _detect_product_category(corpus), + }, + "registration_type": { + "label": "注册类型", + "input_type": "select", + "options": OPTION_FIELDS["registration_type"], + "suggested": _detect_registration_type(corpus), + }, + "clinical_evaluation_path": { + "label": "临床评价路径", + "input_type": "select", + "options": OPTION_FIELDS["clinical_evaluation_path"], + "suggested": _detect_clinical_path(corpus), + }, + "product_name": { + "label": "产品名称", + "input_type": "text", + "suggested": product_name, + "source": field_sources.get("产品名称", "summary" if product_name else ""), + }, + "model_spec": { + "label": "型号规格", + "input_type": "text", + "suggested": field_candidates.get("型号规格", ""), + "source": field_sources.get("型号规格", ""), + }, + "intended_use": { + "label": "预期用途", + "input_type": "text", + "suggested": field_candidates.get("预期用途", ""), + "source": field_sources.get("预期用途", ""), + }, + } + + +def _extract_item_fields(item) -> dict[str, object]: + path = Path(item.storage_path) + if not path.is_absolute(): + path = Path(settings.MEDIA_ROOT) / item.storage_path + if not path.exists(): + return {} + result = extract_text(path) + if result.status != "success" or not result.text: + return {} + inferred_fields = _infer_fields_from_text(result.front_text or result.text) + rule_fields = {**inferred_fields, **(result.field_candidates or {})} + review = review_condition_fields( + text=result.front_text or result.text, + rule_fields=rule_fields, + file_context=f"{item.directory_level}\n{item.file_name}\n{item.relative_path}", + ) + selected_sources = dict(review.get("selected_sources") or {}) + for key in inferred_fields: + if selected_sources.get(key) == "rule" and key not in (result.field_candidates or {}): + selected_sources[key] = "inferred" + review["selected_sources"] = selected_sources + review["front_text"] = result.front_text or result.text[:1200] + return review + + +def _safe_summary_product_name(product_name: str) -> str: + value = (product_name or "").strip() + if not value: + return "" + if any(keyword in value for keyword in ["第1章", "第2章", "监管信息", "综述资料", "非临床资料", "章节目录"]): + return "" + return value + + +def _infer_fields_from_text(text: str) -> dict[str, str]: + normalized = _normalize_text_for_inference(text) + fields = {} + product_name = _infer_product_name(normalized) + if product_name: + fields["产品名称"] = product_name + model_spec = _infer_model_spec(normalized) + if model_spec: + fields["型号规格"] = model_spec + return fields + + +def _normalize_text_for_inference(text: str) -> str: + value = re.sub(r"\s+", "", text or "") + value = value.replace("(", "(").replace(")", ")") + return value + + +def _infer_product_name(text: str) -> str: + patterns = [ + r"体外诊断试剂(?P[^。;;,,]{4,120}?试剂盒\([^()]{2,30}\))产品注册", + r"(?P[^。;;,,]{4,120}?试剂盒\([^()]{2,30}\))", + ] + for pattern in patterns: + match = re.search(pattern, text) + if match: + return _restore_chinese_parentheses(_trim_product_name(match.group("name"))) + return "" + + +def _trim_product_name(value: str) -> str: + prefixes = ["申请境内第三类体外诊断试剂", "申请境内第二类体外诊断试剂", "境内第三类体外诊断试剂", "境内第二类体外诊断试剂"] + result = value + for prefix in prefixes: + if prefix in result: + result = result.split(prefix, 1)[-1] + return result + + +def _infer_model_spec(text: str) -> str: + specs = sorted(set(re.findall(r"规格[A-ZA-Z]", text))) + if specs: + return "、".join(specs) + match = re.search(r"产品的包装规格(?P.{1,80}?(?:人份/盒|测试/盒|反应/盒)(?:[、,,].{1,30}?(?:人份/盒|测试/盒|反应/盒))*)", text) + if not match: + return "" + return _restore_chinese_parentheses(match.group("spec").strip("::,,。;;")) + + +def _restore_chinese_parentheses(value: str) -> str: + return value.replace("(", "(").replace(")", ")") + + +def _condition_candidates_incomplete(candidates: dict[str, dict[str, object]]) -> bool: + if not candidates: + return True + product_name = str((candidates.get("product_name") or {}).get("suggested") or "").strip() + product_category = str((candidates.get("product_category") or {}).get("suggested") or "").strip() + return not product_name or "�" in product_name or product_category == "其他" + + +def _merge_condition_candidates( + current: dict[str, dict[str, object]], + refreshed: dict[str, dict[str, object]], +) -> dict[str, dict[str, object]]: + merged = {**(current or {})} + for field, config in (refreshed or {}).items(): + current_config = merged.get(field) or {} + current_value = str(current_config.get("suggested") or "").strip() + refreshed_value = str((config or {}).get("suggested") or "").strip() + if _is_better_condition_value(current_value, refreshed_value): + merged[field] = config + elif field not in merged: + merged[field] = config + return merged + + +def _is_better_condition_value(current_value: str, refreshed_value: str) -> bool: + if not refreshed_value: + return False + if "�" in refreshed_value: + return False + if "�" in current_value: + return True + if not current_value: + return True + if current_value == "其他" and refreshed_value != "其他": + return True + if current_value == "待确认" and refreshed_value != "待确认": + return True + return len(refreshed_value) > len(current_value) and current_value in refreshed_value + + +def _detect_product_category(corpus: str) -> str: + if any(keyword in corpus for keyword in ["体外诊断", "检测试剂", "试剂盒", "IVD"]): + return "体外诊断试剂" + if "医疗器械" in corpus: + return "医疗器械" + return "其他" + + +def _detect_registration_type(corpus: str) -> str: + if "延续" in corpus: + return "延续注册" + if "变更" in corpus: + return "变更注册" + return "首次注册" + + +def _detect_clinical_path(corpus: str) -> str: + if "免临床" in corpus or "免于临床" in corpus: + return "免临床" + if "同品种" in corpus or "同类" in corpus: + return "同品种比对" + if "临床试验" in corpus: + return "临床试验" + return "待确认" diff --git a/review_agent/regulatory_review/services/llm_review.py b/review_agent/regulatory_review/services/llm_review.py new file mode 100644 index 0000000..9988c60 --- /dev/null +++ b/review_agent/regulatory_review/services/llm_review.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import json +import os +import re +import time +import inspect +from collections.abc import Callable +from typing import Any + +from django.conf import settings + +from review_agent.llm import LLMConfigurationError, LLMRequestError, generate_completion + + +FIELD_LABELS = ["产品名称", "型号规格", "预期用途", "管理类别", "分类编码", "注册类型", "临床评价路径"] +CompletionFunc = Callable[[list[dict[str, str]]], str] + + +def review_condition_fields( + *, + text: str, + rule_fields: dict[str, str], + file_context: str = "", + completion_func: Callable[..., str] | None = None, +) -> dict[str, Any]: + llm_fields: dict[str, str] = {} + status = "skipped" + error_message = "" + if not _should_call_llm(completion_func): + selected_fields, selected_sources = _select_fields(rule_fields, llm_fields) + return { + "status": status, + "error_message": error_message, + "rule_fields": _clean_fields(rule_fields), + "llm_fields": llm_fields, + "selected_fields": selected_fields, + "selected_sources": selected_sources, + } + try: + raw = _call_completion_with_retries( + completion_func or generate_completion, + _condition_messages(text, rule_fields, file_context), + ) + payload = _parse_json_object(raw) + llm_fields = _clean_fields(payload.get("fields") or payload) + status = "success" + except (LLMConfigurationError, LLMRequestError, json.JSONDecodeError, TypeError, ValueError, OSError, TimeoutError) as exc: + status = "failed" + error_message = str(exc) + + selected_fields, selected_sources = _select_fields(rule_fields, llm_fields) + return { + "status": status, + "error_message": error_message, + "rule_fields": _clean_fields(rule_fields), + "llm_fields": llm_fields, + "selected_fields": selected_fields, + "selected_sources": selected_sources, + } + + +def review_workflow_payload( + *, + stage: str, + payload: dict[str, Any], + completion_func: Callable[..., str] | None = None, +) -> dict[str, Any]: + if not _should_call_llm(completion_func): + return { + "status": "skipped", + "stage": stage, + "result": {}, + "error_message": "", + } + try: + raw = _call_completion_with_retries( + completion_func or generate_completion, + _workflow_messages(stage, payload), + ) + parsed = _parse_json_object(raw) + return { + "status": "success", + "stage": stage, + "result": parsed, + "error_message": "", + } + except (LLMConfigurationError, LLMRequestError, json.JSONDecodeError, TypeError, ValueError, OSError, TimeoutError) as exc: + return { + "status": "failed", + "stage": stage, + "result": {}, + "error_message": str(exc), + } + + +def _condition_messages(text: str, rule_fields: dict[str, str], file_context: str) -> list[dict[str, str]]: + return [ + { + "role": "system", + "content": ( + "你是NMPA注册资料字段复核助手。请从附件文本中提取最合理的字段值," + "只返回JSON,格式为 {\"fields\": {\"产品名称\": \"...\"}}。" + "产品名称应包含完整名称、检测对象和方法学括号;不要把章节标题当产品名称。" + ), + }, + { + "role": "user", + "content": json.dumps( + { + "file_context": file_context, + "rule_fields": rule_fields, + "text": text[:4000], + "allowed_fields": FIELD_LABELS, + }, + ensure_ascii=False, + ), + }, + ] + + +def _workflow_messages(stage: str, payload: dict[str, Any]) -> list[dict[str, str]]: + return [ + { + "role": "system", + "content": ( + "你是NMPA法规核查复核助手。请复核当前流程节点的规则结果," + "指出可能误判、漏判和更合理的建议。只返回JSON。" + ), + }, + { + "role": "user", + "content": json.dumps({"stage": stage, "payload": payload}, ensure_ascii=False)[:6000], + }, + ] + + +def _parse_json_object(raw: str) -> dict[str, Any]: + value = (raw or "").strip() + if value.startswith("```"): + value = re.sub(r"^```(?:json)?\s*", "", value) + value = re.sub(r"\s*```$", "", value) + start = value.find("{") + end = value.rfind("}") + if start >= 0 and end >= start: + value = value[start : end + 1] + parsed = json.loads(value) + if not isinstance(parsed, dict): + raise ValueError("LLM复核结果不是JSON对象。") + return parsed + + +def _call_completion_with_retries(completion_func: Callable[..., str], messages: list[dict[str, str]]) -> str: + attempts = max(1, int(getattr(settings, "REGULATORY_LLM_REVIEW_MAX_ATTEMPTS", 3) or 3)) + delay_seconds = float(getattr(settings, "REGULATORY_LLM_REVIEW_RETRY_DELAY_SECONDS", 0.5) or 0) + timeout_seconds = float(getattr(settings, "REGULATORY_LLM_REVIEW_TIMEOUT_SECONDS", 15) or 15) + accepts_timeout = _accepts_timeout(completion_func) + last_error: Exception | None = None + for attempt in range(1, attempts + 1): + try: + if accepts_timeout: + return completion_func(messages, temperature=0.0, timeout=timeout_seconds) + return completion_func(messages, temperature=0.0) + except (LLMRequestError, OSError, TimeoutError) as exc: + last_error = exc + if attempt >= attempts: + break + if delay_seconds > 0: + time.sleep(delay_seconds) + if last_error: + raise last_error + raise LLMRequestError("LLM复核调用失败。") + + +def _accepts_timeout(completion_func: Callable[..., str]) -> bool: + try: + signature = inspect.signature(completion_func) + except (TypeError, ValueError): + return True + return "timeout" in signature.parameters + + +def _should_call_llm(completion_func: Callable[..., str] | None) -> bool: + if completion_func is not None: + return True + if os.environ.get("PYTEST_CURRENT_TEST") and not getattr(settings, "REGULATORY_LLM_REVIEW_ALLOW_TEST_CALLS", False): + return False + return bool(settings.LLM_API_KEY and settings.LLM_MODEL) + + +def _clean_fields(fields: dict[str, Any]) -> dict[str, str]: + clean = {} + for label in FIELD_LABELS: + value = fields.get(label) + if not isinstance(value, str): + continue + normalized = " ".join(value.strip().split()).replace("(", "(").replace(")", ")") + if normalized: + clean[label] = normalized + return clean + + +def _select_fields(rule_fields: dict[str, str], llm_fields: dict[str, str]) -> tuple[dict[str, str], dict[str, str]]: + rule_clean = _clean_fields(rule_fields) + selected = {} + sources = {} + for label in FIELD_LABELS: + rule_value = rule_clean.get(label, "") + llm_value = llm_fields.get(label, "") + value, source = _select_field(label, rule_value, llm_value) + if value: + selected[label] = value + sources[label] = source + return selected, sources + + +def _select_field(label: str, rule_value: str, llm_value: str) -> tuple[str, str]: + if _invalid_field_value(llm_value): + return rule_value, "rule" if rule_value else "" + if not rule_value: + return llm_value, "llm" if llm_value else "" + if not llm_value: + return rule_value, "rule" + if label == "产品名称" and _better_product_name(llm_value, rule_value): + return llm_value, "llm" + if len(llm_value) > len(rule_value) * 1.35 and rule_value in llm_value: + return llm_value, "llm" + return rule_value, "rule" + + +def _better_product_name(candidate: str, current: str) -> bool: + if current and current in candidate and len(candidate) > len(current): + return True + product_keywords = ["试剂盒", "检测试剂", "荧光PCR法", "PCR法", "核酸检测"] + return len(candidate) > len(current) and any(keyword in candidate for keyword in product_keywords) + + +def _invalid_field_value(value: str) -> bool: + if not value: + return True + if "�" in value: + return True + return any(keyword in value for keyword in ["第1章", "第2章", "第3章", "监管信息", "综述资料", "章节目录"]) diff --git a/review_agent/regulatory_review/services/rag_citation.py b/review_agent/regulatory_review/services/rag_citation.py new file mode 100644 index 0000000..7afca0d --- /dev/null +++ b/review_agent/regulatory_review/services/rag_citation.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from pathlib import Path + +from django.conf import settings + +from .rag_embedding import EmbeddingFunction, get_embedding_provider + + +class RagIndexUnavailable(RuntimeError): + pass + + +def retrieve_citations( + query: str, + *, + embedding_provider: EmbeddingFunction | None = None, + collection=None, + n_results: int = 3, +) -> list[dict[str, object]]: + provider = embedding_provider or get_embedding_provider() + if collection is None: + collection = _load_collection() + embeddings = provider([query]) + result = collection.query(query_embeddings=embeddings, n_results=n_results) + documents = (result.get("documents") or [[]])[0] + metadatas = (result.get("metadatas") or [[]])[0] + distances = (result.get("distances") or [[]])[0] + if not documents: + return [{"source": "原文依据待补充", "text": "RAG 无命中", "score": None}] + citations = [] + for index, document in enumerate(documents): + metadata = metadatas[index] if index < len(metadatas) else {} + distance = distances[index] if index < len(distances) else None + citations.append( + { + "source": metadata.get("source", "法规材料"), + "text": document, + "score": distance, + "metadata": metadata, + } + ) + return citations + + +def _load_collection(): + persist_path = Path(settings.REGULATORY_RAG_CHROMA_PATH) + if not persist_path.exists(): + raise RagIndexUnavailable("法规 RAG 索引不存在,请先运行 regulatory_rag_build。") + try: + import chromadb + except ImportError as exc: + raise RagIndexUnavailable("chromadb 未安装,请先安装 requirements.txt。") from exc + client = chromadb.PersistentClient(path=str(persist_path)) + try: + return client.get_collection(settings.REGULATORY_RAG_COLLECTION) + except Exception as exc: + raise RagIndexUnavailable("法规 RAG collection 不存在,请先运行 regulatory_rag_build。") from exc diff --git a/review_agent/regulatory_review/services/rag_embedding.py b/review_agent/regulatory_review/services/rag_embedding.py new file mode 100644 index 0000000..d50de0e --- /dev/null +++ b/review_agent/regulatory_review/services/rag_embedding.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import hashlib +import random +from typing import Callable, Iterable + +import httpx +from django.conf import settings + + +EmbeddingFunction = Callable[[list[str]], list[list[float]]] + + +class EmbeddingConfigurationError(RuntimeError): + pass + + +class SiliconFlowEmbeddingProvider: + def __init__( + self, + *, + api_key: str, + base_url: str, + model: str, + dimensions: int, + timeout: float = 60.0, + ): + if not api_key: + raise EmbeddingConfigurationError("SILICONFLOW_API_KEY 未配置。") + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self.model = model + self.dimensions = dimensions + self.timeout = timeout + + def embed(self, texts: Iterable[str]) -> list[list[float]]: + inputs = list(texts) + response = httpx.post( + f"{self.base_url}/embeddings", + headers={"Authorization": f"Bearer {self.api_key}"}, + json={ + "model": self.model, + "input": inputs, + "dimensions": self.dimensions, + }, + timeout=self.timeout, + ) + response.raise_for_status() + payload = response.json() + return [item["embedding"] for item in payload.get("data", [])] + + def __call__(self, texts: list[str]) -> list[list[float]]: + return self.embed(texts) + + +class DeterministicEmbeddingProvider: + """Small local embedding substitute for tests and explicit dry runs.""" + + def __init__(self, dimensions: int = 16): + self.dimensions = dimensions + + def __call__(self, texts: list[str]) -> list[list[float]]: + vectors = [] + for text in texts: + seed = int(hashlib.sha256(text.encode("utf-8")).hexdigest()[:16], 16) + rng = random.Random(seed) + vectors.append([rng.uniform(-1, 1) for _ in range(self.dimensions)]) + return vectors + + +def get_embedding_provider(provider_name: str | None = None) -> EmbeddingFunction: + provider = provider_name or settings.REGULATORY_RAG_PROVIDER + if provider == "siliconflow": + return SiliconFlowEmbeddingProvider( + api_key=settings.SILICONFLOW_API_KEY, + base_url=settings.SILICONFLOW_BASE_URL, + model=settings.SILICONFLOW_EMBEDDING_MODEL, + dimensions=settings.SILICONFLOW_EMBEDDING_DIMENSIONS, + ) + if provider in {"deterministic", "local"}: + return DeterministicEmbeddingProvider() + raise EmbeddingConfigurationError(f"不支持的 embedding provider:{provider}") diff --git a/review_agent/regulatory_review/services/rag_index.py b/review_agent/regulatory_review/services/rag_index.py new file mode 100644 index 0000000..3e58826 --- /dev/null +++ b/review_agent/regulatory_review/services/rag_index.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import hashlib +import logging +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from django.conf import settings +from docx import Document +from docx.oxml.table import CT_Tbl +from docx.oxml.text.paragraph import CT_P +from docx.table import Table +from docx.text.paragraph import Paragraph +from openpyxl import load_workbook +from pypdf import PdfReader +from pptx import Presentation + +from .rag_embedding import EmbeddingFunction + + +logger = logging.getLogger("review_agent.regulatory_review.rag_index") + +EXCLUDED_SOURCE_KEYWORDS = ("模拟题二", "试剂盒临床注册文件准备与审核Agent") + + +@dataclass(frozen=True) +class TextChunk: + text: str + metadata: dict[str, object] + + +def chunk_text(text: str, *, source: str, chunk_size: int = 900, overlap: int = 120) -> list[TextChunk]: + normalized = "\n".join(line.strip() for line in text.splitlines() if line.strip()) + if not normalized: + return [] + chunks = [] + start = 0 + index = 0 + step = max(1, chunk_size - overlap) + while start < len(normalized): + part = normalized[start : start + chunk_size].strip() + if part: + chunks.append(TextChunk(text=part, metadata={"source": source, "chunk_index": index})) + index += 1 + start += step + return chunks + + +def extract_text_from_path(path: Path) -> str: + suffix = path.suffix.lower() + if suffix in {".txt", ".md"}: + return path.read_text(encoding="utf-8", errors="ignore") + if suffix == ".pdf": + return "\n".join(page.extract_text() or "" for page in PdfReader(str(path)).pages) + if suffix == ".docx": + return _extract_docx_text(path) + if suffix == ".pptx": + presentation = Presentation(str(path)) + lines = [] + for slide in presentation.slides: + for shape in slide.shapes: + if hasattr(shape, "text"): + lines.append(shape.text) + return "\n".join(lines) + if suffix == ".xlsx": + workbook = load_workbook(path, data_only=True, read_only=True) + lines = [] + for sheet in workbook.worksheets: + for row in sheet.iter_rows(values_only=True): + values = [str(cell) for cell in row if cell not in {None, ""}] + if values: + lines.append("\t".join(values)) + return "\n".join(lines) + if suffix == ".doc": + return _extract_legacy_doc_with_libreoffice(path) + return "" + + +def _extract_docx_text(path: Path) -> str: + document = Document(str(path)) + lines: list[str] = [] + for block in _iter_docx_blocks(document): + if isinstance(block, Paragraph): + text = block.text.strip() + if text: + lines.append(text) + elif isinstance(block, Table): + for row in block.rows: + values = [cell.text.strip() for cell in row.cells if cell.text.strip()] + if values: + lines.append("\t".join(values)) + return "\n".join(lines) + + +def _iter_docx_blocks(document): + body = document.element.body + for child in body.iterchildren(): + if isinstance(child, CT_P): + yield Paragraph(child, document) + elif isinstance(child, CT_Tbl): + yield Table(child, document) + + +def _extract_legacy_doc_with_libreoffice(path: Path) -> str: + cached = _cached_docx_path(path) + if cached.exists(): + return extract_text_from_path(cached) + try: + return _extract_legacy_doc_with_libreoffice_convert(path) + except RuntimeError as libreoffice_error: + try: + return _extract_legacy_doc_with_word_com(path) + except RuntimeError as word_error: + try: + return _extract_legacy_doc_with_powershell_word_com(path) + except RuntimeError as powershell_error: + raise RuntimeError( + f"无法转换法规 .doc 材料:{path.name};" + f"LibreOffice 错误:{libreoffice_error};" + f"Word COM 错误:{word_error};" + f"PowerShell Word COM 错误:{powershell_error}" + ) from powershell_error + + +def _cached_docx_path(path: Path) -> Path: + digest = hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:12] + cache_dir = Path(settings.MEDIA_ROOT) / "regulatory_review" / "docx_cache" + return cache_dir / f"{path.stem}-{digest}.docx" + + +def _extract_legacy_doc_with_libreoffice_convert(path: Path) -> str: + with tempfile.TemporaryDirectory() as tmp_dir: + target_dir = Path(tmp_dir) + try: + subprocess.run( + [ + "soffice", + "--headless", + "--convert-to", + "docx", + "--outdir", + str(target_dir), + str(path), + ], + check=True, + capture_output=True, + text=True, + timeout=60, + ) + except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + raise RuntimeError(f"无法通过 LibreOffice 转换法规 .doc 材料:{path.name}") from exc + converted = target_dir / f"{path.stem}.docx" + if not converted.exists(): + raise RuntimeError(f"LibreOffice 未生成 docx:{path.name}") + return extract_text_from_path(converted) + + +def _extract_legacy_doc_with_word_com(path: Path) -> str: + with tempfile.TemporaryDirectory() as tmp_dir: + target_dir = Path(tmp_dir) + converted = target_dir / f"{path.stem}.docx" + word = None + try: + import pythoncom + import win32com.client + + pythoncom.CoInitialize() + word = win32com.client.DispatchEx("Word.Application") + word.Visible = False + document = word.Documents.Open(str(path.resolve()), ReadOnly=True) + document.SaveAs(str(converted.resolve()), FileFormat=16) + document.Close(False) + except Exception as exc: + raise RuntimeError(f"无法通过 Word COM 转换法规 .doc 材料:{path.name}") from exc + finally: + if word is not None: + try: + word.Quit() + except Exception: + pass + try: + pythoncom.CoUninitialize() + except Exception: + pass + if not converted.exists(): + raise RuntimeError(f"Word COM 未生成 docx:{path.name}") + return extract_text_from_path(converted) + + +def _extract_legacy_doc_with_powershell_word_com(path: Path) -> str: + with tempfile.TemporaryDirectory() as tmp_dir: + target_dir = Path(tmp_dir) + converted = target_dir / f"{path.stem}.docx" + source_path = str(path.resolve()).replace("'", "''") + target_path = str(converted.resolve()).replace("'", "''") + script = ( + "$ErrorActionPreference = 'Stop';" + "$word = New-Object -ComObject Word.Application;" + "$word.Visible = $false;" + "try {" + f"$doc = $word.Documents.Open('{source_path}', $false, $true);" + f"$doc.SaveAs([ref]'{target_path}', [ref]16);" + "$doc.Close([ref]$false);" + "} finally { $word.Quit() }" + ) + powershell = shutil.which("powershell") or shutil.which("pwsh") + if not powershell: + raise RuntimeError("PowerShell 不可用,无法调用 Word COM。") + try: + subprocess.run( + [powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], + check=True, + capture_output=True, + text=True, + timeout=90, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + raise RuntimeError(f"无法通过 PowerShell Word COM 转换法规 .doc 材料:{path.name}") from exc + if not converted.exists(): + raise RuntimeError(f"PowerShell Word COM 未生成 docx:{path.name}") + return extract_text_from_path(converted) + + +def collect_source_chunks(source_dir: Path) -> list[TextChunk]: + chunks: list[TextChunk] = [] + for path in sorted(source_dir.rglob("*")): + if not path.is_file(): + continue + if is_excluded_source_path(path.relative_to(source_dir)): + continue + try: + text = extract_text_from_path(path) + except RuntimeError as exc: + if _is_attachment4(path): + raise RuntimeError(f"附件 4 核心法规材料抽取失败:{path.name}") from exc + logger.warning("Regulatory source extraction skipped", extra={"path": str(path), "error": str(exc)}) + continue + chunks.extend(chunk_text(text, source=str(path.relative_to(source_dir)))) + return chunks + + +def is_excluded_source_path(path: Path | str) -> bool: + normalized = str(path) + return any(keyword in normalized for keyword in EXCLUDED_SOURCE_KEYWORDS) + + +def _is_attachment4(path: Path) -> bool: + normalized = path.name.replace(" ", "") + return "附件4" in normalized and "体外诊断试剂注册申报资料要求及说明" in normalized + + +def build_chroma_index( + *, + source_dir: Path, + embedding_provider: EmbeddingFunction, + persist_path: Path | None = None, + collection_name: str | None = None, + reset: bool = False, +) -> int: + try: + import chromadb + except ImportError as exc: + raise RuntimeError("chromadb 未安装,请先安装 requirements.txt。") from exc + + persist_path = persist_path or Path(settings.REGULATORY_RAG_CHROMA_PATH) + collection_name = collection_name or settings.REGULATORY_RAG_COLLECTION + persist_path.mkdir(parents=True, exist_ok=True) + chunks = collect_source_chunks(source_dir) + try: + client = chromadb.PersistentClient(path=str(persist_path)) + except Exception: + if not reset: + raise + clear_chroma_system_cache() + clear_chroma_index_dir(persist_path) + persist_path.mkdir(parents=True, exist_ok=True) + client = chromadb.PersistentClient(path=str(persist_path)) + if reset: + try: + client.delete_collection(collection_name) + clear_chroma_system_cache() + client = chromadb.PersistentClient(path=str(persist_path)) + except Exception: + pass + collection = client.get_or_create_collection(collection_name) + if not chunks: + return 0 + texts = [chunk.text for chunk in chunks] + embeddings = embedding_provider(texts) + ids = [ + hashlib.sha256(f"{chunk.metadata['source']}:{chunk.metadata['chunk_index']}".encode("utf-8")).hexdigest() + for chunk in chunks + ] + collection.upsert( + ids=ids, + documents=texts, + metadatas=[chunk.metadata for chunk in chunks], + embeddings=embeddings, + ) + return len(chunks) + + +def clear_chroma_index_dir(persist_path: Path | str | None = None) -> None: + chroma_path = Path(persist_path or settings.REGULATORY_RAG_CHROMA_PATH).resolve() + media_root = Path(settings.MEDIA_ROOT).resolve() + try: + chroma_path.relative_to(media_root) + except ValueError as exc: + raise RuntimeError("法规 RAG 索引目录必须位于 MEDIA_ROOT 内。") from exc + if chroma_path.exists(): + shutil.rmtree(chroma_path) + + +def clear_chroma_system_cache() -> None: + try: + from chromadb.api.shared_system_client import SharedSystemClient + except Exception: + return + SharedSystemClient.clear_system_cache() diff --git a/review_agent/regulatory_review/services/rectification_review.py b/review_agent/regulatory_review/services/rectification_review.py new file mode 100644 index 0000000..cc0863f --- /dev/null +++ b/review_agent/regulatory_review/services/rectification_review.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import json +from django.utils import timezone + +from review_agent.models import FileSummaryBatch, RegulatoryIssue, RegulatoryReviewBatch +from review_agent.regulatory_review.services.rule_loader import load_rule_file +from review_agent.regulatory_review.storage import save_artifact + + +def review_missing_issues( + *, + batch: RegulatoryReviewBatch, + issue_ids: list[int], + file_summary_batch: FileSummaryBatch, +) -> dict[str, object]: + rule_set = load_rule_file() + rules_by_code = {rule["code"]: rule for rule in rule_set.get("requirements", [])} + items = list(file_summary_batch.items.order_by("file_index")) + record = { + "type": "review_record", + "reviewed_at": timezone.localtime().isoformat(), + "source_review_batch_id": batch.pk, + "source_review_batch_no": batch.batch_no, + "file_summary_batch_id": file_summary_batch.pk, + "file_summary_batch_no": file_summary_batch.batch_no, + "items": [], + } + issues = RegulatoryIssue.objects.filter(batch=batch, pk__in=issue_ids).order_by("id") + for issue in issues: + rule = rules_by_code.get(issue.rule_code, {}) + matched_files = _match_items(items, [*rule.get("file_keywords", []), issue.title]) + passed = bool(matched_files) + issue.status = RegulatoryIssue.Status.REVIEW_PASSED if passed else RegulatoryIssue.Status.REVIEW_FAILED + issue.evidence = { + **(issue.evidence or {}), + "latest_review": { + "file_summary_batch_id": file_summary_batch.pk, + "file_summary_batch_no": file_summary_batch.batch_no, + "matched_files": matched_files, + }, + } + issue.save(update_fields=["status", "evidence", "updated_at"]) + record["items"].append( + { + "issue_id": issue.pk, + "rule_code": issue.rule_code, + "title": issue.title, + "status": issue.status, + "matched_files": matched_files, + } + ) + artifact = save_artifact( + batch, + name=f"review_record_{timezone.now().strftime('%Y%m%d%H%M%S')}.json", + artifact_type="json", + content=json.dumps(record, ensure_ascii=False, indent=2), + metadata={"artifact": "review_record", "file_summary_batch_id": file_summary_batch.pk}, + ) + record["artifact_id"] = artifact.pk + return record + + +def _match_items(items, keywords: list[str]) -> list[dict[str, str]]: + normalized_keywords = [str(keyword).lower() for keyword in keywords if keyword] + matched = [] + for item in items: + haystack = f"{item.file_name} {item.relative_path} {item.directory_level}".lower() + if any(keyword in haystack for keyword in normalized_keywords): + matched.append( + { + "file_name": item.file_name, + "relative_path": item.relative_path, + "directory_level": item.directory_level, + } + ) + return matched diff --git a/review_agent/regulatory_review/services/risk_assess.py b/review_agent/regulatory_review/services/risk_assess.py new file mode 100644 index 0000000..5f342d7 --- /dev/null +++ b/review_agent/regulatory_review/services/risk_assess.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections import Counter + +from review_agent.models import RegulatoryIssue, RegulatoryReviewBatch +from review_agent.regulatory_review.schemas import Finding + +from .rag_citation import retrieve_citations + + +SEVERITY_ORDER = ["blocking", "high", "medium", "low", "info"] + + +def persist_findings(batch: RegulatoryReviewBatch, findings: list[Finding]) -> list[RegulatoryIssue]: + RegulatoryIssue.objects.filter(batch=batch).delete() + unique = {} + for finding in findings: + unique.setdefault((finding.rule_code, finding.category, finding.title), finding) + + issues = [] + for finding in unique.values(): + citations = finding.citations or _safe_citations(finding) + issues.append( + RegulatoryIssue.objects.create( + batch=batch, + rule_code=finding.rule_code, + category=finding.category, + severity=finding.severity, + title=finding.title, + detail=finding.detail, + suggestion=finding.suggestion, + evidence=finding.evidence, + citations=citations, + ) + ) + batch.risk_summary = _risk_summary(issues) + batch.save(update_fields=["risk_summary"]) + return issues + + +def _safe_citations(finding: Finding) -> list[dict[str, object]]: + try: + return retrieve_citations(finding.title) + except Exception: + return [{"source": "原文依据待补充", "text": "RAG 索引不可用或无命中", "score": None}] + + +def _risk_summary(issues: list[RegulatoryIssue]) -> dict[str, int]: + counts = Counter(issue.severity for issue in issues) + return {severity: counts.get(severity, 0) for severity in SEVERITY_ORDER} diff --git a/review_agent/regulatory_review/services/rule_loader.py b/review_agent/regulatory_review/services/rule_loader.py new file mode 100644 index 0000000..85855ad --- /dev/null +++ b/review_agent/regulatory_review/services/rule_loader.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from pathlib import Path + +import yaml +from django.conf import settings + +from review_agent.models import RegulatoryRuleVersion + + +DEFAULT_RULE_CODE = "nmpa_ivd_registration_v1" +DEFAULT_RULE_PATH = ( + Path(settings.BASE_DIR) + / "review_agent" + / "regulatory_review" + / "rules" + / "nmpa_ivd_registration_v1.yaml" +) + + +@dataclass(frozen=True) +class RuleVersionCheck: + status: str + code: str + path: Path + current_hash: str + database_hash: str = "" + record: RegulatoryRuleVersion | None = None + + +def compute_file_sha256(path: str | Path) -> str: + file_path = Path(path) + digest = hashlib.sha256() + with file_path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def load_rule_file(path: str | Path | None = None) -> dict: + rule_path = Path(path) if path else DEFAULT_RULE_PATH + with rule_path.open("r", encoding="utf-8") as handle: + payload = yaml.safe_load(handle) or {} + if payload.get("code") != DEFAULT_RULE_CODE: + raise ValueError(f"规则 code 必须为 {DEFAULT_RULE_CODE}") + if not isinstance(payload.get("requirements"), list) or not payload["requirements"]: + raise ValueError("规则文件必须包含 requirements 列表。") + _validate_attachment4_requirements(payload) + return payload + + +def _validate_attachment4_requirements(payload: dict) -> None: + requirements = payload.get("requirements") or [] + required_codes = {str(code) for code in payload.get("attachment4_required_codes") or []} + by_attachment4_code: dict[str, list[dict]] = {} + for requirement in requirements: + attachment4_code = requirement.get("attachment4_code") + if attachment4_code: + by_attachment4_code.setdefault(str(attachment4_code), []).append(requirement) + for field in ["code", "rule_id", "title", "severity", "file_keywords", "citation_query"]: + if attachment4_code and not requirement.get(field): + raise ValueError(f"附件4规则 {attachment4_code} 缺少 {field}") + missing = sorted(required_codes - set(by_attachment4_code), key=_attachment4_sort_key) + if missing: + raise ValueError(f"附件4目录项缺少规则:{', '.join(missing)}") + + +def _attachment4_sort_key(value: str) -> tuple[int, ...]: + return tuple(int(part) for part in value.split(".") if part.isdigit()) + + +def check_rule_version( + *, + path: str | Path | None = None, + update_missing: bool = True, +) -> RuleVersionCheck: + rule_path = Path(path) if path else DEFAULT_RULE_PATH + rule_set = load_rule_file(rule_path) + current_hash = compute_file_sha256(rule_path) + record = RegulatoryRuleVersion.objects.filter(code=rule_set["code"]).first() + yaml_path = str(rule_path.relative_to(settings.BASE_DIR)) + + if record is None: + if not update_missing: + return RuleVersionCheck( + status="missing", + code=rule_set["code"], + path=rule_path, + current_hash=current_hash, + ) + record = RegulatoryRuleVersion.objects.create( + code=rule_set["code"], + name=rule_set.get("name") or rule_set["code"], + yaml_path=yaml_path, + yaml_hash=current_hash, + rag_collection=rule_set.get("rag_collection", ""), + status=RegulatoryRuleVersion.Status.ACTIVE, + ) + return RuleVersionCheck( + status="created", + code=record.code, + path=rule_path, + current_hash=current_hash, + database_hash=record.yaml_hash, + record=record, + ) + + if record.yaml_hash != current_hash: + return RuleVersionCheck( + status="mismatch", + code=record.code, + path=rule_path, + current_hash=current_hash, + database_hash=record.yaml_hash, + record=record, + ) + + return RuleVersionCheck( + status="ok", + code=record.code, + path=rule_path, + current_hash=current_hash, + database_hash=record.yaml_hash, + record=record, + ) diff --git a/review_agent/regulatory_review/services/structure_check.py b/review_agent/regulatory_review/services/structure_check.py new file mode 100644 index 0000000..efe8a40 --- /dev/null +++ b/review_agent/regulatory_review/services/structure_check.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from collections.abc import Callable + +from review_agent.regulatory_review.schemas import Finding + + +def run_structure_check( + document_texts: dict[str, str], + rule_set: dict, + progress_callback: Callable[[dict[str, object]], None] | None = None, +) -> list[Finding]: + findings: list[Finding] = [] + combined_all_text = "\n".join(document_texts.values()) + requirements = list(rule_set.get("requirements", [])) + total = len(requirements) + for index, requirement in enumerate(requirements, start=1): + if requirement.get("structure_required") and not _contains_any( + combined_all_text, + [requirement.get("title", ""), *requirement.get("aliases", [])], + ): + findings.append( + Finding( + rule_code=requirement["code"], + category="structure", + severity=requirement.get("severity", "medium"), + title=f"申报资料目录缺少{_numbered_title(requirement)}章节", + detail=f"未在申报资料目录或章节标题候选中发现{_numbered_title(requirement)}。", + suggestion=requirement.get("suggestion", ""), + evidence={ + "attachment4_code": requirement.get("attachment4_code"), + "expected_title": requirement["title"], + "aliases": requirement.get("aliases", []), + }, + ) + ) + required_sections = requirement.get("required_sections") or [] + if required_sections: + matching_docs = _matching_documents(document_texts, requirement.get("file_keywords", [])) + if matching_docs: + combined_text = "\n".join(matching_docs.values()) + for section in required_sections: + if _contains_any(combined_text, [section]): + continue + findings.append( + Finding( + rule_code=f"{requirement['code']}:{section}", + category="structure", + severity=requirement.get("severity", "medium"), + title=f"{requirement['title']}缺少{section}章节", + detail=f"已匹配{requirement['title']}文件,但未发现{section}相关内容。", + suggestion=requirement.get("suggestion", ""), + evidence={"section": section, "files": list(matching_docs)}, + ) + ) + if progress_callback: + progress_callback( + { + "processed": index, + "total": total, + "label": _numbered_title(requirement), + "finding_count": len(findings), + } + ) + return findings + + +def _matching_documents(document_texts: dict[str, str], keywords: list[str]) -> dict[str, str]: + if not keywords: + return document_texts + result = {} + for name, text in document_texts.items(): + haystack = f"{name}\n{text}".lower() + if any(str(keyword).lower() in haystack for keyword in keywords): + result[name] = text + return result + + +def _contains_any(text: str, needles: list[str]) -> bool: + normalized = _normalize_title(text) + return any(_normalize_title(needle) in normalized for needle in needles if needle) + + +def _normalize_title(value: str) -> str: + return "".join(str(value).lower().replace("/", "").replace("/", "").split()) + + +def _numbered_title(requirement: dict) -> str: + attachment4_code = requirement.get("attachment4_code") + if not attachment4_code: + return requirement["title"] + return f"{attachment4_code}{requirement['title']}" diff --git a/review_agent/regulatory_review/services/text_extract.py b/review_agent/regulatory_review/services/text_extract.py new file mode 100644 index 0000000..3b98e51 --- /dev/null +++ b/review_agent/regulatory_review/services/text_extract.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from pathlib import Path + +from .rag_index import extract_text_from_path + + +@dataclass(frozen=True) +class ExtractedText: + path: Path + text: str + status: str + content_hash: str = "" + error_message: str = "" + front_text: str = "" + section_candidates: list[str] | None = None + field_candidates: dict[str, str] | None = None + + +SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx", ".pptx", ".xlsx", ".doc"} +FIELD_LABELS = ["产品名称", "型号规格", "预期用途", "管理类别", "分类编码", "注册类型", "临床评价路径"] + + +def extract_text(path: str | Path) -> ExtractedText: + file_path = Path(path) + if file_path.suffix.lower() not in SUPPORTED_EXTENSIONS: + return ExtractedText(path=file_path, text="", status="unsupported") + try: + text = extract_text_from_path(file_path) + except Exception as exc: + return ExtractedText( + path=file_path, + text="", + status="failed", + error_message=str(exc), + section_candidates=[], + field_candidates={}, + ) + content_hash = hashlib.sha256(text.encode("utf-8")).hexdigest() if text else "" + return ExtractedText( + path=file_path, + text=text, + status="success", + content_hash=content_hash, + front_text=_front_text(text), + section_candidates=_section_candidates(text), + field_candidates=_field_candidates(text), + ) + + +def _front_text(text: str, limit: int = 1200) -> str: + return text[:limit] + + +def _section_candidates(text: str) -> list[str]: + candidates = [] + for line in text.splitlines(): + normalized = line.strip() + if not normalized: + continue + if re.match(r"^([一二三四五六七八九十]+[、..]|[0-9]+(\.[0-9]+)*[、..\s])", normalized): + candidates.append(normalized[:120]) + elif any(keyword in normalized for keyword in ["章节目录", "监管信息", "综述资料", "非临床资料", "临床评价资料", "质量管理体系"]): + candidates.append(normalized[:120]) + return candidates[:80] + + +def _field_candidates(text: str) -> dict[str, str]: + fields = {} + lines = text.splitlines() + for index, line in enumerate(lines): + normalized = line.strip() + if not normalized: + continue + for label in FIELD_LABELS: + match = re.match(rf"^{re.escape(label)}[::]\s*(.*)$", normalized) + if not match or label in fields: + continue + value_parts = [match.group(1).strip()] + for next_line in lines[index + 1 :]: + continuation = next_line.strip() + if not continuation or _starts_field_line(continuation) or _looks_like_section_heading(continuation): + break + value_parts.append(continuation) + value = " ".join(part for part in value_parts if part) + if value: + fields[label] = " ".join(value.split()) + return fields + + +def _starts_field_line(line: str) -> bool: + if any(re.match(rf"^{re.escape(label)}[::]", line) for label in FIELD_LABELS): + return True + return bool(re.match(r"^[^\s::]{2,24}[::]", line)) + + +def _looks_like_section_heading(line: str) -> bool: + return bool(re.match(r"^([一二三四五六七八九十]+[、..]|[0-9]+(\.[0-9]+)*[、..\s])", line)) diff --git a/review_agent/regulatory_review/storage.py b/review_agent/regulatory_review/storage.py new file mode 100644 index 0000000..9d53006 --- /dev/null +++ b/review_agent/regulatory_review/storage.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path + +from django.conf import settings + +from review_agent.models import RegulatoryArtifact, RegulatoryReviewBatch + + +def save_artifact( + batch: RegulatoryReviewBatch, + *, + name: str, + content: str | bytes, + artifact_type: str, + metadata: dict | None = None, +) -> RegulatoryArtifact: + root = Path(batch.work_dir) if batch.work_dir else Path(settings.MEDIA_ROOT) / "regulatory_review" / "work" / batch.batch_no + root.mkdir(parents=True, exist_ok=True) + path = root / Path(name).name + if isinstance(content, bytes): + path.write_bytes(content) + digest = hashlib.sha256(content).hexdigest() + else: + path.write_text(content, encoding="utf-8") + digest = hashlib.sha256(content.encode("utf-8")).hexdigest() + return RegulatoryArtifact.objects.create( + batch=batch, + artifact_type=artifact_type, + name=path.name, + storage_path=str(path), + content_hash=digest, + metadata=metadata or {}, + ) diff --git a/review_agent/regulatory_review/views.py b/review_agent/regulatory_review/views.py new file mode 100644 index 0000000..c244dea --- /dev/null +++ b/review_agent/regulatory_review/views.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import json + +from django.conf import settings +from django.http import Http404, JsonResponse +from django.views.decorators.http import require_http_methods +from django.contrib.auth.decorators import login_required + +from review_agent.models import FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun +from review_agent.notifications.presenter import serialize_notification_records +from review_agent.regulatory_review.events import record_event +from review_agent.regulatory_review.services.info_extract import ensure_regulatory_condition_candidates +from review_agent.regulatory_review.services.rectification_review import review_missing_issues +from review_agent.regulatory_review.workflow import create_regulatory_review_batch, start_regulatory_review_workflow + + +@require_http_methods(["GET"]) +@login_required +def batch_status(request, batch_id: int): + batch = RegulatoryReviewBatch.objects.filter(pk=batch_id, user=request.user).first() + if not batch: + raise Http404("批次不存在。") + condition_candidates = ensure_regulatory_condition_candidates(batch) + nodes = WorkflowNodeRun.objects.filter( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + ).order_by("id") + notifications = serialize_notification_records("regulatory_review", batch.pk) + payload = { + "batch": { + "id": batch.pk, + "workflow_type": "regulatory_review", + "batch_no": batch.batch_no, + "status": batch.status, + "source_summary_batch_id": batch.source_summary_batch_id, + "risk_summary": batch.risk_summary, + "risk_summary_text": _format_risk_summary(batch.risk_summary or {}), + "error_message": batch.error_message, + }, + "nodes": [ + { + "node_code": node.node_code, + "node_name": node.node_name, + "status": node.status, + "progress": node.progress, + "message": node.message, + } + for node in nodes + ], + "notifications": notifications, + "latest_notification": notifications[0] if notifications else None, + } + if batch.status == RegulatoryReviewBatch.Status.WAITING_USER and condition_candidates: + payload["condition_confirmation"] = { + "batch_id": batch.pk, + "batch_no": batch.batch_no, + "confirm_url": f"/api/review-agent/regulatory-review/{batch.pk}/conditions/", + "candidates": condition_candidates, + } + return JsonResponse(payload) + + +@require_http_methods(["POST"]) +@login_required +def confirm_conditions(request, batch_id: int): + batch = RegulatoryReviewBatch.objects.filter(pk=batch_id, user=request.user).first() + if not batch: + raise Http404("批次不存在。") + try: + payload = json.loads(request.body.decode("utf-8") or "{}") + except json.JSONDecodeError: + return JsonResponse({"error": "请求体不是有效 JSON。"}, status=400) + conditions = payload.get("conditions") + if not isinstance(conditions, dict): + return JsonResponse({"error": "conditions 必须是对象。"}, status=400) + + batch.condition_json = { + **(batch.condition_json or {}), + "confirmed": True, + "confirmed_conditions": _normalize_conditions(conditions), + } + batch.status = RegulatoryReviewBatch.Status.RUNNING + batch.save(update_fields=["condition_json", "status"]) + WorkflowNodeRun.objects.filter( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + node_code="condition_confirm", + ).update( + status=WorkflowNodeRun.Status.SUCCESS, + progress=100, + message="适用条件已确认", + ) + record_event( + batch, + "condition_confirmed", + {"conditions": batch.condition_json["confirmed_conditions"], "resume_from": "rule_scope"}, + ) + start_regulatory_review_workflow( + batch, + async_run=getattr(settings, "REGULATORY_REVIEW_ASYNC", True), + ) + batch.refresh_from_db() + return JsonResponse( + { + "batch": { + "id": batch.pk, + "workflow_type": "regulatory_review", + "batch_no": batch.batch_no, + "status": batch.status, + "condition_json": batch.condition_json, + } + } + ) + + +@require_http_methods(["POST"]) +@login_required +def start_full_review(request, batch_id: int): + source_batch = RegulatoryReviewBatch.objects.filter(pk=batch_id, user=request.user).first() + if not source_batch: + raise Http404("批次不存在。") + payload, error_response = _json_payload(request) + if error_response: + return error_response + summary_batch = FileSummaryBatch.objects.filter( + pk=payload.get("file_summary_batch_id"), + conversation=source_batch.conversation, + user=request.user, + status=FileSummaryBatch.Status.SUCCESS, + ).first() + if not summary_batch: + return JsonResponse({"error": "file_summary_batch_id 不存在或未成功。"}, status=400) + new_batch = create_regulatory_review_batch( + conversation=source_batch.conversation, + user=request.user, + source_summary_batch=summary_batch, + ) + new_batch.condition_json = { + "source_review_batch_id": source_batch.pk, + "regenerated_from": { + "batch_id": source_batch.pk, + "batch_no": source_batch.batch_no, + "file_summary_batch_id": source_batch.source_summary_batch_id, + "file_summary_batch_no": source_batch.source_summary_batch.batch_no, + }, + "confirmed": True, + "confirmed_conditions": source_batch.condition_json.get("confirmed_conditions", {}), + } + new_batch.save(update_fields=["condition_json"]) + record_event( + new_batch, + "full_package_review_started", + {"source_review_batch_id": source_batch.pk, "source_review_batch_no": source_batch.batch_no}, + ) + start_regulatory_review_workflow( + new_batch, + async_run=getattr(settings, "REGULATORY_REVIEW_ASYNC", True), + ) + new_batch.refresh_from_db() + return JsonResponse( + { + "batch": { + "id": new_batch.pk, + "workflow_type": "regulatory_review", + "batch_no": new_batch.batch_no, + "status": new_batch.status, + "source_review_batch_id": source_batch.pk, + } + } + ) + + +@require_http_methods(["POST"]) +@login_required +def review_issues(request, batch_id: int): + batch = RegulatoryReviewBatch.objects.filter(pk=batch_id, user=request.user).first() + if not batch: + raise Http404("批次不存在。") + payload, error_response = _json_payload(request) + if error_response: + return error_response + issue_ids = payload.get("issue_ids") + if not isinstance(issue_ids, list): + return JsonResponse({"error": "issue_ids 必须是列表。"}, status=400) + summary_batch = FileSummaryBatch.objects.filter( + pk=payload.get("file_summary_batch_id"), + conversation=batch.conversation, + user=request.user, + status=FileSummaryBatch.Status.SUCCESS, + ).first() + if not summary_batch: + return JsonResponse({"error": "file_summary_batch_id 不存在或未成功。"}, status=400) + record = review_missing_issues(batch=batch, issue_ids=[int(item) for item in issue_ids], file_summary_batch=summary_batch) + return JsonResponse({"review_record": record}) + + +def _format_risk_summary(risk_summary: dict) -> str: + labels = [ + ("blocking", "阻断项"), + ("high", "高风险"), + ("medium", "中风险"), + ("low", "低风险"), + ("info", "提示"), + ] + return " · ".join( + f"{label} {int(risk_summary.get(key) or 0)}" + for key, label in labels + if int(risk_summary.get(key) or 0) + ) + + +def _normalize_conditions(conditions: dict) -> dict[str, str]: + allowed = [ + "product_category", + "registration_type", + "clinical_evaluation_path", + "product_name", + "model_spec", + "intended_use", + ] + return {key: str(conditions.get(key) or "").strip() for key in allowed} + + +def _json_payload(request): + try: + return json.loads(request.body.decode("utf-8") or "{}"), None + except json.JSONDecodeError: + return {}, JsonResponse({"error": "请求体不是有效 JSON。"}, status=400) diff --git a/review_agent/regulatory_review/workflow.py b/review_agent/regulatory_review/workflow.py new file mode 100644 index 0000000..c9b492c --- /dev/null +++ b/review_agent/regulatory_review/workflow.py @@ -0,0 +1,575 @@ +from __future__ import annotations + +import json +import logging +import re +from pathlib import Path +from threading import Thread +from uuid import uuid4 + +from django.conf import settings +from django.db import transaction +from django.utils import timezone + +from review_agent.models import ( + Conversation, + FileSummaryBatch, + Message, + RegulatoryReviewBatch, + WorkflowNodeRun, +) +from review_agent.notifications.dispatcher import dispatch_workflow_notification +from review_agent.notifications.workflow_adapters import build_regulatory_review_context +from review_agent.regulatory_review.services.completeness_check import run_completeness_check +from review_agent.regulatory_review.services.consistency_check import run_consistency_check +from review_agent.regulatory_review.services.export import build_assistant_summary, export_review_results +from review_agent.regulatory_review.services.feishu_notifier import create_mock_notifications +from review_agent.regulatory_review.services.info_extract import detect_regulatory_condition_candidates +from review_agent.regulatory_review.services.llm_review import review_condition_fields, review_workflow_payload +from review_agent.regulatory_review.services.risk_assess import persist_findings +from review_agent.regulatory_review.services.rule_loader import load_rule_file +from review_agent.regulatory_review.services.structure_check import run_structure_check +from review_agent.regulatory_review.services.text_extract import extract_text + +from .events import record_event +from .storage import save_artifact + + +NODE_DEFINITIONS = [ + ("prepare", "准备", "prepare"), + ("condition_confirm", "适用条件确认", "condition_confirm"), + ("rule_scope", "规则范围", "rule_scope"), + ("completeness_check", "完整性核查", "completeness_check"), + ("text_extract", "文本抽取", "text_extract"), + ("structure_check", "章节核查", "structure_check"), + ("consistency_check", "一致性核查", "consistency_check"), + ("risk_assess", "风险评估", "risk_assess"), + ("report_export", "报告输出", "report_export"), + ("completed", "完成", "completed"), +] + + +logger = logging.getLogger("review_agent.regulatory_review.workflow") + + +ATTACHMENT4_CHAPTER_LABELS = { + "1": "第1章 监管信息", + "2": "第2章 综述资料", + "3": "第3章 非临床资料", + "4": "第4章 临床评价资料", + "5": "第5章 产品说明书和标签样稿", + "6": "第6章 质量管理体系文件", +} + + +class WorkflowPausedForUser(Exception): + pass + + +def build_batch_no() -> str: + return f"RR-{timezone.localtime().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6]}" + + +def build_batch_work_dir(batch_no: str) -> Path: + return Path(settings.MEDIA_ROOT) / "regulatory_review" / "work" / batch_no + + +def find_latest_successful_summary_batch(conversation: Conversation) -> FileSummaryBatch | None: + return ( + FileSummaryBatch.objects.filter( + conversation=conversation, + status=FileSummaryBatch.Status.SUCCESS, + ) + .order_by("-finished_at", "-created_at", "-id") + .first() + ) + + +@transaction.atomic +def create_regulatory_review_batch( + *, + conversation: Conversation, + user, + source_summary_batch: FileSummaryBatch, + trigger_message: Message | None = None, +) -> RegulatoryReviewBatch: + batch_no = build_batch_no() + work_dir = build_batch_work_dir(batch_no) + work_dir.mkdir(parents=True, exist_ok=True) + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + trigger_message=trigger_message, + source_summary_batch=source_summary_batch, + batch_no=batch_no, + work_dir=str(work_dir), + condition_json=_initial_condition_json(trigger_message), + ) + for code, name, group in NODE_DEFINITIONS: + WorkflowNodeRun.objects.create( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + node_group=group, + node_code=code, + node_name=name, + ) + record_event(batch, "workflow_created", {"batch_id": batch.pk, "batch_no": batch.batch_no}) + return batch + + +class RegulatoryWorkflowExecutor: + def __init__(self, batch: RegulatoryReviewBatch): + self.batch = batch + self.rule_set: dict | None = None + self.findings = [] + self.document_texts: dict[str, str] = {} + self.text_extract_status: dict[str, dict[str, object]] = {} + self.llm_reviews: dict[str, dict[str, object]] = {} + + def run(self) -> None: + logger.info("法规核查工作流开始 batch_no=%s batch_id=%s", self.batch.batch_no, self.batch.pk) + self.batch.status = RegulatoryReviewBatch.Status.RUNNING + self.batch.started_at = timezone.now() + self.batch.save(update_fields=["status", "started_at"]) + record_event(self.batch, "workflow_started", {"batch_id": self.batch.pk}) + + try: + for node in self._nodes(): + if node.status == WorkflowNodeRun.Status.SUCCESS: + continue + self._run_node(node) + except WorkflowPausedForUser: + logger.info("法规核查工作流等待用户 batch_no=%s node=condition_confirm", self.batch.batch_no) + return + except Exception as exc: + logger.exception("Regulatory workflow failed", extra={"batch_id": self.batch.pk}) + self.batch.status = RegulatoryReviewBatch.Status.FAILED + self.batch.error_message = str(exc) + self.batch.finished_at = timezone.now() + self.batch.save(update_fields=["status", "error_message", "finished_at"]) + record_event(self.batch, "workflow_failed", {"message": str(exc)}) + self._dispatch_completion_notification() + return + + self.batch.status = RegulatoryReviewBatch.Status.SUCCESS + self.batch.finished_at = timezone.now() + self.batch.save(update_fields=["status", "finished_at"]) + record_event(self.batch, "workflow_completed", {"batch_id": self.batch.pk}) + self._dispatch_completion_notification() + logger.info("法规核查工作流完成 batch_no=%s findings=%s", self.batch.batch_no, len(self.findings)) + + def _dispatch_completion_notification(self) -> None: + try: + dispatch_workflow_notification(build_regulatory_review_context(self.batch)) + except Exception as exc: + logger.warning( + "Regulatory review notification failed without blocking workflow", + extra={"batch_id": self.batch.pk, "error": str(exc)}, + ) + + def _nodes(self): + return WorkflowNodeRun.objects.filter( + workflow_type="regulatory_review", + workflow_batch_id=self.batch.pk, + ).order_by("id") + + def _run_node(self, node: WorkflowNodeRun) -> None: + logger.info( + "节点开始 batch_no=%s node=%s name=%s", + self.batch.batch_no, + node.node_code, + node.node_name, + ) + node.status = WorkflowNodeRun.Status.RUNNING + node.progress = 10 + node.started_at = timezone.now() + node.message = f"{node.node_name}处理中" + node.save(update_fields=["status", "progress", "started_at", "message"]) + record_event( + self.batch, + "node_progress", + {"node_code": node.node_code, "status": node.status, "progress": node.progress, "message": node.message}, + ) + + self._execute_node(node) + + node.status = WorkflowNodeRun.Status.SUCCESS + node.progress = 100 + node.finished_at = timezone.now() + node.message = f"{node.node_name}完成" + node.save(update_fields=["status", "progress", "finished_at", "message"]) + record_event( + self.batch, + "node_progress", + {"node_code": node.node_code, "status": node.status, "progress": node.progress, "message": node.message}, + ) + logger.info( + "节点完成 batch_no=%s node=%s name=%s progress=%s", + self.batch.batch_no, + node.node_code, + node.node_name, + node.progress, + ) + + def _update_node_progress( + self, + node: WorkflowNodeRun, + *, + processed: int, + total: int, + message: str, + ) -> None: + if total <= 0: + return + progress = min(95, 10 + int((max(processed, 0) / total) * 85)) + node.progress = progress + node.message = message + node.save(update_fields=["progress", "message"]) + record_event( + self.batch, + "node_progress", + { + "node_code": node.node_code, + "status": node.status, + "progress": node.progress, + "message": node.message, + "processed": processed, + "total": total, + }, + ) + logger.info( + "节点进度 batch_no=%s node=%s progress=%s processed=%s total=%s message=%s", + self.batch.batch_no, + node.node_code, + progress, + processed, + total, + message, + ) + + def _execute_node(self, node: WorkflowNodeRun) -> None: + node_code = node.node_code + if node_code == "condition_confirm": + self._pause_for_condition_confirmation() + return + if node_code == "rule_scope": + self.rule_set = apply_rule_scope(load_rule_file(), self.batch.condition_json.get("rule_scope") or {}) + logger.info( + "方法执行 batch_no=%s method=apply_rule_scope requirements=%s scope=%s", + self.batch.batch_no, + len(self.rule_set.get("requirements", [])), + self.batch.condition_json.get("rule_scope") or {}, + ) + return + if node_code == "completeness_check": + findings = run_completeness_check( + self.batch.source_summary_batch, + self._rules(), + progress_callback=lambda update: self._update_node_progress( + node, + processed=int(update.get("processed") or 0), + total=int(update.get("total") or 0), + message=( + f"完整性核查 {update.get('processed')}/{update.get('total')}:" + f"{update.get('label') or ''},发现{update.get('finding_count') or 0}项问题" + ), + ), + ) + self.findings.extend(findings) + logger.info( + "方法执行 batch_no=%s method=run_completeness_check findings=%s source_summary=%s", + self.batch.batch_no, + len(findings), + self.batch.source_summary_batch.batch_no, + ) + self._save_llm_review( + "completeness_check", + { + "findings": [finding.to_dict() for finding in findings], + "rules_count": len(self._rules().get("requirements", [])), + }, + ) + return + if node_code == "text_extract": + self.document_texts = self._extract_source_texts(node) + logger.info( + "方法执行 batch_no=%s method=_extract_source_texts success_docs=%s total_files=%s", + self.batch.batch_no, + len(self.document_texts), + len(self.text_extract_status), + ) + self._save_llm_review("text_extract", {"files": self.text_extract_status}) + save_artifact( + self.batch, + name="text_extract_status.json", + artifact_type="json", + content=json.dumps(self.text_extract_status, ensure_ascii=False, indent=2), + metadata={"artifact": "text_extract_status"}, + ) + return + if node_code == "structure_check": + findings = run_structure_check( + self.document_texts, + self._rules(), + progress_callback=lambda update: self._update_node_progress( + node, + processed=int(update.get("processed") or 0), + total=int(update.get("total") or 0), + message=( + f"章节核查 {update.get('processed')}/{update.get('total')}:" + f"{update.get('label') or ''},发现{update.get('finding_count') or 0}项问题" + ), + ), + ) + self.findings.extend(findings) + logger.info( + "方法执行 batch_no=%s method=run_structure_check findings=%s docs=%s", + self.batch.batch_no, + len(findings), + len(self.document_texts), + ) + self._save_llm_review("structure_check", {"findings": [finding.to_dict() for finding in findings]}) + return + if node_code == "consistency_check": + findings = run_consistency_check( + self.document_texts, + progress_callback=lambda update: self._update_node_progress( + node, + processed=int(update.get("processed") or 0), + total=int(update.get("total") or 0), + message=( + f"一致性核查 {update.get('processed')}/{update.get('total')}:" + f"{update.get('label') or ''},发现{update.get('finding_count') or 0}项问题" + ), + ), + ) + self.findings.extend(findings) + logger.info( + "方法执行 batch_no=%s method=run_consistency_check findings=%s docs=%s", + self.batch.batch_no, + len(findings), + len(self.document_texts), + ) + self._save_llm_review("consistency_check", {"findings": [finding.to_dict() for finding in findings]}) + return + if node_code == "risk_assess": + self._save_llm_review("risk_assess", {"findings": [finding.to_dict() for finding in self.findings]}) + issues = persist_findings(self.batch, self.findings) + create_mock_notifications(self.batch) + logger.info( + "方法执行 batch_no=%s method=persist_findings issues=%s findings=%s", + self.batch.batch_no, + len(issues), + len(self.findings), + ) + save_artifact( + self.batch, + name="rag_result_json.json", + artifact_type="json", + content=json.dumps( + { + "batch_no": self.batch.batch_no, + "text_extract_status": self.text_extract_status, + "issues": [ + { + "rule_code": issue.rule_code, + "title": issue.title, + "citations": issue.citations, + } + for issue in issues + ], + "llm_reviews": self.llm_reviews, + }, + ensure_ascii=False, + indent=2, + ), + metadata={"artifact": "rag_result_json"}, + ) + return + if node_code == "report_export": + exports = export_review_results(self.batch) + logger.info( + "方法执行 batch_no=%s method=export_review_results exports=%s", + self.batch.batch_no, + len(exports), + ) + Message.objects.create( + conversation=self.batch.conversation, + role=Message.Role.ASSISTANT, + content=build_assistant_summary(self.batch, exports), + ) + + def _pause_for_condition_confirmation(self) -> None: + if self.batch.condition_json.get("confirmed"): + return + candidates = detect_regulatory_condition_candidates(self.batch.source_summary_batch) + logger.info( + "方法执行 batch_no=%s method=detect_regulatory_condition_candidates product_category=%s product_name=%s", + self.batch.batch_no, + (candidates.get("product_category") or {}).get("suggested"), + (candidates.get("product_name") or {}).get("suggested"), + ) + self.batch.condition_json = { + **(self.batch.condition_json or {}), + "confirmed": False, + "resume_from": "rule_scope", + "candidates": candidates, + } + self.batch.status = RegulatoryReviewBatch.Status.WAITING_USER + self.batch.save(update_fields=["status", "condition_json"]) + node = WorkflowNodeRun.objects.get( + workflow_type="regulatory_review", + workflow_batch_id=self.batch.pk, + node_code="condition_confirm", + ) + node.status = WorkflowNodeRun.Status.WAITING_USER + node.progress = 50 + node.message = "请确认产品类别、注册类型、临床评价路径等适用条件" + node.save(update_fields=["status", "progress", "message"]) + record_event( + self.batch, + "waiting_user", + {"node_code": "condition_confirm", "candidates": candidates, "resume_from": "rule_scope"}, + ) + raise WorkflowPausedForUser() + + def _rules(self) -> dict: + if self.rule_set is None: + self.rule_set = apply_rule_scope(load_rule_file(), self.batch.condition_json.get("rule_scope") or {}) + return self.rule_set + + def _extract_source_texts(self, node: WorkflowNodeRun | None = None) -> dict[str, str]: + texts = {} + items = list(self.batch.source_summary_batch.items.order_by("file_index")) + total = len(items) + for index, item in enumerate(items, start=1): + path = Path(item.storage_path) + if not path.is_absolute(): + path = Path(settings.MEDIA_ROOT) / item.storage_path + if not path.exists(): + logger.info("文本抽取跳过 batch_no=%s file=%s reason=missing", self.batch.batch_no, item.file_name) + self.text_extract_status[item.file_name] = { + "status": "missing", + "path": str(path), + "content_hash": "", + "section_candidates": [], + "field_candidates": {}, + "front_text": "", + } + if node: + self._update_node_progress( + node, + processed=index, + total=total, + message=f"文本抽取 {index}/{total}:{item.file_name}(文件不存在)", + ) + continue + result = extract_text(path) + field_review = review_condition_fields( + text=result.front_text or result.text, + rule_fields=result.field_candidates or {}, + file_context=f"{item.directory_level}\n{item.file_name}\n{item.relative_path}", + ) + self.text_extract_status[item.file_name] = { + "status": result.status, + "path": str(path), + "content_hash": result.content_hash, + "section_candidates": result.section_candidates, + "field_candidates": field_review.get("selected_fields", result.field_candidates), + "field_review": field_review, + "front_text": result.front_text, + "error_message": result.error_message, + } + if result.status == "success" and result.text: + texts[item.file_name] = result.text + logger.info( + "文本抽取文件 batch_no=%s file=%s status=%s fields=%s chars=%s", + self.batch.batch_no, + item.file_name, + result.status, + len((field_review.get("selected_fields") or {})), + len(result.text or ""), + ) + if node: + self._update_node_progress( + node, + processed=index, + total=total, + message=f"文本抽取 {index}/{total}:{item.file_name}({result.status})", + ) + return texts + + def _save_llm_review(self, stage: str, payload: dict[str, object]) -> dict[str, object]: + review = review_workflow_payload(stage=stage, payload=payload) + self.llm_reviews[stage] = review + logger.info( + "方法执行 batch_no=%s method=review_workflow_payload stage=%s status=%s", + self.batch.batch_no, + stage, + review.get("status"), + ) + save_artifact( + self.batch, + name=f"llm_review_{stage}.json", + artifact_type="json", + content=json.dumps(review, ensure_ascii=False, indent=2), + metadata={"artifact": "llm_review", "stage": stage}, + ) + return review + + +def start_regulatory_review_workflow(batch: RegulatoryReviewBatch, *, async_run: bool = True) -> None: + executor = RegulatoryWorkflowExecutor(batch) + if not async_run: + executor.run() + return + Thread(target=executor.run, daemon=True).start() + + +def _initial_condition_json(trigger_message: Message | None) -> dict: + scope = detect_attachment4_chapter_scope(trigger_message.content if trigger_message else "") + return {"rule_scope": scope} if scope else {} + + +def detect_attachment4_chapter_scope(content: str) -> dict[str, str] | None: + normalized = (content or "").strip() + if not normalized: + return None + chapter = _extract_chapter_number(normalized) + if chapter not in ATTACHMENT4_CHAPTER_LABELS: + return None + return {"attachment4_chapter": chapter, "label": ATTACHMENT4_CHAPTER_LABELS[chapter]} + + +def apply_rule_scope(rule_set: dict, rule_scope: dict) -> dict: + chapter = str(rule_scope.get("attachment4_chapter") or "") + if chapter not in ATTACHMENT4_CHAPTER_LABELS: + return rule_set + scoped = {**rule_set} + scoped["requirements"] = [ + requirement + for requirement in rule_set.get("requirements", []) + if _requirement_in_chapter(requirement, chapter) + ] + scoped["active_rule_scope"] = rule_scope + return scoped + + +def _requirement_in_chapter(requirement: dict, chapter: str) -> bool: + attachment4_code = str(requirement.get("attachment4_code") or "") + return attachment4_code == chapter or attachment4_code.startswith(f"{chapter}.") + + +def _extract_chapter_number(content: str) -> str: + match = re.search(r"第\s*([一二三四五六1-6])\s*[章节张]", content) + if match: + return _normalize_chapter_number(match.group(1)) + match = re.search(r"(^|[^\d])([1-6])\s*[章节张]", content) + if match: + return match.group(2) + return "" + + +def _normalize_chapter_number(value: str) -> str: + chinese = {"一": "1", "二": "2", "三": "3", "四": "4", "五": "5", "六": "6"} + return chinese.get(value, value) diff --git a/review_agent/services.py b/review_agent/services.py new file mode 100644 index 0000000..bd12ad8 --- /dev/null +++ b/review_agent/services.py @@ -0,0 +1,815 @@ +from __future__ import annotations + +import json +import logging +from pathlib import Path + +from django.db.models import Q, QuerySet +from django.conf import settings +from django.utils import timezone + +from .file_summary.skills.attachment_reader import AttachmentReaderSkill +from .file_summary.workflow import create_file_summary_batch, start_file_summary_workflow +from .knowledge_base import search_knowledge_base +from .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply +from .models import Conversation, FileAttachment, FileSummaryBatch, FileSummaryBatchAttachment, KnowledgeBaseDocument, Message +from .regulatory_review.services.rag_index import extract_text_from_path +from .application_form_fill.workflow import ( + create_application_form_fill_batch, + find_latest_successful_summary_batch as find_latest_successful_form_fill_summary_batch, + start_application_form_fill_workflow, +) +from .regulatory_info_package.constants import WORKFLOW_TYPE as REGULATORY_INFO_PACKAGE_WORKFLOW_TYPE +from .regulatory_info_package.services.input_select import select_instruction_input +from .regulatory_info_package.workflow import ( + create_regulatory_info_package_batch, + start_regulatory_info_package_workflow, +) +from .regulatory_review.workflow import ( + create_regulatory_review_batch, + find_latest_successful_summary_batch, + start_regulatory_review_workflow, +) +from .skill_router import route_message_intent + + +logger = logging.getLogger(__name__) + + +def list_conversations(user, search: str = "") -> QuerySet[Conversation]: + """Returns a user's conversations, optionally filtered by title or content.""" + + conversations = Conversation.objects.filter(user=user) + if not search: + return conversations + + return conversations.filter( + Q(title__icontains=search) | Q(messages__content__icontains=search) + ).distinct() + + +def get_conversation_for_user(user, conversation_id: int | None) -> Conversation | None: + """Loads a conversation only when it belongs to the current user.""" + + if not conversation_id: + return None + return Conversation.objects.filter(user=user, pk=conversation_id).first() + + +def create_conversation(user) -> Conversation: + """Creates an empty conversation that can immediately accept messages.""" + + now = timezone.localtime() + return Conversation.objects.create( + user=user, + title=f"新对话 {now.strftime('%m-%d %H:%M')}", + ) + + +def append_user_message(conversation: Conversation, content: str) -> Message: + """Appends a user message and updates the conversation title from the first prompt.""" + + message = Message.objects.create( + conversation=conversation, + role=Message.Role.USER, + content=content.strip(), + ) + logger.info( + "User message appended", + extra={ + "conversation_id": conversation.pk, + "message_id": message.pk, + "content_length": len(message.content), + }, + ) + + if conversation.messages.filter(role=Message.Role.USER).count() == 1: + conversation.title = build_conversation_title(content) + conversation.save(update_fields=["title", "updated_at"]) + + return message + + +def append_assistant_message(conversation: Conversation, content: str) -> Message: + """Appends the deterministic assistant reply.""" + + message = Message.objects.create( + conversation=conversation, + role=Message.Role.ASSISTANT, + content=content, + ) + logger.info( + "Assistant message appended", + extra={ + "conversation_id": conversation.pk, + "message_id": message.pk, + "content_length": len(content or ""), + }, + ) + return message + + +def send_message(conversation: Conversation, content: str) -> tuple[Message, Message]: + """Stores one user message and one provider-backed assistant reply.""" + + user_message = append_user_message(conversation, content) + knowledge_context = build_knowledge_context(content) + if should_refuse_ungrounded_chat(conversation, content, knowledge_context): + reply_content = out_of_scope_reply() + else: + try: + reply_content = generate_reply(conversation, content, knowledge_context=knowledge_context) + except (LLMConfigurationError, LLMRequestError) as exc: + reply_content = f"模型调用失败:{exc}" + + assistant_message = append_assistant_message(conversation, reply_content) + + if conversation.title.startswith("新对话"): + conversation.title = build_conversation_title(content) + conversation.save(update_fields=["title", "updated_at"]) + + return user_message, assistant_message + + +def stream_message(conversation: Conversation, content: str): + """Yields SSE events while collecting a streamed assistant reply.""" + + user_message = append_user_message(conversation, content) + assistant_parts: list[str] = [] + knowledge_context = build_knowledge_context(content) + + if should_refuse_ungrounded_chat(conversation, content, knowledge_context): + reply_content = out_of_scope_reply() + assistant_message = append_assistant_message(conversation, reply_content) + yield sse_event( + "meta", + { + "conversation_id": conversation.pk, + "title": conversation.title or build_conversation_title(content), + "user_message_id": user_message.pk, + "user_message": user_message.content, + }, + ) + yield sse_event("chunk", {"delta": reply_content}) + yield sse_event( + "done", + { + "assistant_message_id": assistant_message.pk, + "conversation_id": conversation.pk, + "title": conversation.title, + }, + ) + return + + route = route_message_intent(conversation, content) + logger.info( + "Stream message started", + extra={ + "conversation_id": conversation.pk, + "user_message_id": user_message.pk, + "route_action": route.action, + "route_source": route.source, + "route_confidence": route.confidence, + "route_reason": route.reason, + }, + ) + + yield sse_event( + "meta", + { + "conversation_id": conversation.pk, + "title": conversation.title or build_conversation_title(content), + "user_message_id": user_message.pk, + "user_message": user_message.content, + }, + ) + + if route.starts_file_summary and not _has_active_attachments(conversation): + reply_content = "请先在当前对话右侧上传需要汇总的文件或压缩包,然后再发送自动汇总指令。" + assistant_message = append_assistant_message(conversation, reply_content) + yield sse_event("chunk", {"delta": reply_content}) + yield sse_event( + "done", + { + "assistant_message_id": assistant_message.pk, + "conversation_id": conversation.pk, + "title": conversation.title, + }, + ) + return + + if route.uses_attachment_reader and not _has_active_attachments(conversation): + reply_content = "请先在当前对话右侧上传需要阅读的附件,然后再发送解析或阅读附件指令。" + assistant_message = append_assistant_message(conversation, reply_content) + yield sse_event("chunk", {"delta": reply_content}) + yield sse_event( + "done", + { + "assistant_message_id": assistant_message.pk, + "conversation_id": conversation.pk, + "title": conversation.title, + }, + ) + return + + if route.uses_attachment_reader: + attachments = _select_attachments_for_reader(conversation, content) + logger.info( + "Attachment reader path selected", + extra={ + "conversation_id": conversation.pk, + "attachment_count": len(attachments), + "attachment_ids": [attachment.pk for attachment in attachments], + }, + ) + result = AttachmentReaderSkill().run_for_attachments(attachments) + reply_content = _format_attachment_reader_reply(result.data.get("attachments", []), result.message) + assistant_message = append_assistant_message(conversation, reply_content) + yield sse_event("chunk", {"delta": reply_content}) + yield sse_event( + "done", + { + "assistant_message_id": assistant_message.pk, + "conversation_id": conversation.pk, + "title": conversation.title, + }, + ) + return + + if route.starts_file_summary: + batch = create_file_summary_batch( + conversation=conversation, + user=conversation.user, + trigger_message=user_message, + ) + start_file_summary_workflow( + batch, + async_run=getattr(settings, "FILE_SUMMARY_ASYNC", True), + ) + reply_content = f"已启动文件目录与页数自动汇总工作流,批次号:{batch.batch_no}。" + assistant_message = append_assistant_message(conversation, reply_content) + yield sse_event( + "workflow_started", + { + "workflow_type": "file_summary", + "batch_id": batch.pk, + "batch_no": batch.batch_no, + }, + ) + yield sse_event("chunk", {"delta": reply_content}) + yield sse_event( + "done", + { + "assistant_message_id": assistant_message.pk, + "conversation_id": conversation.pk, + "title": conversation.title, + }, + ) + return + + if route.starts_application_form_fill: + source_summary_batch = find_latest_successful_form_fill_summary_batch(conversation) + if source_summary_batch and not _summary_covers_active_attachments(conversation, source_summary_batch): + source_summary_batch = None + if not source_summary_batch: + if not _has_active_attachments(conversation): + reply_content = "请先在当前对话右侧上传需要填表的产品资料或压缩包,我会先自动汇总再继续生成申报模板。" + assistant_message = append_assistant_message(conversation, reply_content) + yield sse_event("chunk", {"delta": reply_content}) + yield sse_event( + "done", + { + "assistant_message_id": assistant_message.pk, + "conversation_id": conversation.pk, + "title": conversation.title, + }, + ) + return + summary_batch = create_file_summary_batch( + conversation=conversation, + user=conversation.user, + trigger_message=user_message, + ) + yield sse_event( + "workflow_started", + { + "workflow_type": "file_summary", + "batch_id": summary_batch.pk, + "batch_no": summary_batch.batch_no, + }, + ) + start_file_summary_workflow(summary_batch, async_run=False) + summary_batch.refresh_from_db() + if summary_batch.status != FileSummaryBatch.Status.SUCCESS: + reply_content = f"已先启动文件目录与页数自动汇总工作流,批次号:{summary_batch.batch_no},但汇总未成功:{summary_batch.error_message or '原因待查看'}。请处理后再启动申报文件自动填表。" + assistant_message = append_assistant_message(conversation, reply_content) + yield sse_event("chunk", {"delta": reply_content}) + yield sse_event( + "done", + { + "assistant_message_id": assistant_message.pk, + "conversation_id": conversation.pk, + "title": conversation.title, + }, + ) + return + source_summary_batch = summary_batch + reply_prefix = f"已先启动文件目录与页数自动汇总工作流,批次号:{summary_batch.batch_no},汇总完成后继续自动填表。\n" + else: + reply_prefix = "" + batch = create_application_form_fill_batch( + conversation=conversation, + user=conversation.user, + trigger_message=user_message, + source_summary_batch=source_summary_batch, + ) + start_application_form_fill_workflow( + batch, + async_run=getattr(settings, "APPLICATION_FORM_FILL_ASYNC", True), + ) + reply_content = f"{reply_prefix}已启动申报文件自动填表工作流,批次号:{batch.batch_no}。" + assistant_message = append_assistant_message(conversation, reply_content) + yield sse_event( + "workflow_started", + { + "workflow_type": "application_form_fill", + "batch_id": batch.pk, + "batch_no": batch.batch_no, + }, + ) + yield sse_event("chunk", {"delta": reply_content}) + yield sse_event( + "done", + { + "assistant_message_id": assistant_message.pk, + "conversation_id": conversation.pk, + "title": conversation.title, + }, + ) + return + + if route.starts_regulatory_info_package: + selection = select_instruction_input(conversation, content) + if selection.status != "selected": + reply_content = selection.message or "请先在当前对话右侧上传产品说明书 docx 文件,然后再发送第1章监管信息生成指令。" + assistant_message = append_assistant_message(conversation, reply_content) + yield sse_event("chunk", {"delta": reply_content}) + yield sse_event( + "done", + { + "assistant_message_id": assistant_message.pk, + "conversation_id": conversation.pk, + "title": conversation.title, + }, + ) + return + batch = create_regulatory_info_package_batch( + conversation=conversation, + user=conversation.user, + trigger_message=user_message, + source_attachment=selection.attachment, + source_summary_batch=selection.source_summary_batch, + source_summary_item_id=selection.source_summary_item_id, + source_file_name=selection.file_name, + source_storage_path=selection.storage_path, + ) + start_regulatory_info_package_workflow( + batch, + async_run=getattr(settings, "REGULATORY_INFO_PACKAGE_ASYNC", True), + ) + reply_content = f"已启动第1章监管信息材料包生成工作流,批次号:{batch.batch_no}。" + assistant_message = append_assistant_message(conversation, reply_content) + yield sse_event( + "workflow_started", + { + "workflow_type": REGULATORY_INFO_PACKAGE_WORKFLOW_TYPE, + "batch_id": batch.pk, + "batch_no": batch.batch_no, + }, + ) + yield sse_event("chunk", {"delta": reply_content}) + yield sse_event( + "done", + { + "assistant_message_id": assistant_message.pk, + "conversation_id": conversation.pk, + "title": conversation.title, + }, + ) + return + + if route.starts_regulatory_review: + source_summary_batch = find_latest_successful_summary_batch(conversation) + if not source_summary_batch: + if not _has_active_attachments(conversation): + reply_content = "请先在当前对话右侧上传需要核查的文件或压缩包,我会先自动汇总再继续法规核查。" + assistant_message = append_assistant_message(conversation, reply_content) + yield sse_event("chunk", {"delta": reply_content}) + yield sse_event( + "done", + { + "assistant_message_id": assistant_message.pk, + "conversation_id": conversation.pk, + "title": conversation.title, + }, + ) + return + summary_batch = create_file_summary_batch( + conversation=conversation, + user=conversation.user, + trigger_message=user_message, + ) + yield sse_event( + "workflow_started", + { + "workflow_type": "file_summary", + "batch_id": summary_batch.pk, + "batch_no": summary_batch.batch_no, + }, + ) + start_file_summary_workflow(summary_batch, async_run=False) + summary_batch.refresh_from_db() + if summary_batch.status != FileSummaryBatch.Status.SUCCESS: + reply_content = f"已先启动文件目录与页数自动汇总工作流,批次号:{summary_batch.batch_no},但汇总未成功:{summary_batch.error_message or '原因待查看'}。请处理后再启动法规核查。" + assistant_message = append_assistant_message(conversation, reply_content) + yield sse_event("chunk", {"delta": reply_content}) + yield sse_event( + "done", + { + "assistant_message_id": assistant_message.pk, + "conversation_id": conversation.pk, + "title": conversation.title, + }, + ) + return + source_summary_batch = summary_batch + reply_prefix = f"已先启动文件目录与页数自动汇总工作流,批次号:{summary_batch.batch_no},汇总完成后继续法规核查。\n" + else: + reply_prefix = "" + batch = create_regulatory_review_batch( + conversation=conversation, + user=conversation.user, + trigger_message=user_message, + source_summary_batch=source_summary_batch, + ) + start_regulatory_review_workflow( + batch, + async_run=getattr(settings, "REGULATORY_REVIEW_ASYNC", True), + ) + reply_content = f"{reply_prefix}已启动 NMPA 注册资料法规核查工作流,批次号:{batch.batch_no}。" + assistant_message = append_assistant_message(conversation, reply_content) + yield sse_event( + "workflow_started", + { + "workflow_type": "regulatory_review", + "batch_id": batch.pk, + "batch_no": batch.batch_no, + }, + ) + yield sse_event("chunk", {"delta": reply_content}) + yield sse_event( + "done", + { + "assistant_message_id": assistant_message.pk, + "conversation_id": conversation.pk, + "title": conversation.title, + }, + ) + return + + stream_failed = False + stream_error = "" + try: + for chunk in stream_reply(conversation, content, knowledge_context=knowledge_context): + assistant_parts.append(chunk) + yield sse_event("chunk", {"delta": chunk}) + except (LLMConfigurationError, LLMRequestError) as exc: + stream_failed = True + stream_error = str(exc) + logger.warning( + "LLM stream failed", + extra={"conversation_id": conversation.pk, "error": str(exc)}, + ) + except Exception as exc: + stream_failed = True + stream_error = str(exc) + logger.exception( + "Unexpected stream failure", + extra={"conversation_id": conversation.pk, "error": str(exc)}, + ) + + if stream_failed: + try: + fallback_reply = generate_reply(conversation, content, knowledge_context=knowledge_context) + assistant_parts = [fallback_reply] + logger.info( + "Non-stream fallback reply succeeded", + extra={"conversation_id": conversation.pk, "content_length": len(fallback_reply)}, + ) + yield sse_event("replace", {"content": fallback_reply}) + except (LLMConfigurationError, LLMRequestError) as exc: + fallback = f"模型调用失败:{exc}" + assistant_parts = [fallback] + logger.warning( + "Non-stream fallback reply failed", + extra={"conversation_id": conversation.pk, "error": str(exc), "stream_error": stream_error}, + ) + yield sse_event("error", {"message": fallback}) + except Exception as exc: + fallback = f"回复生成中断:{stream_error or exc}" + assistant_parts.append("\n\n" + fallback) + logger.exception( + "Non-stream fallback crashed", + extra={"conversation_id": conversation.pk, "error": str(exc), "stream_error": stream_error}, + ) + yield sse_event("error", {"message": fallback}) + + assistant_message = append_assistant_message(conversation, "".join(assistant_parts).strip()) + + if conversation.title.startswith("新对话"): + conversation.title = build_conversation_title(content) + conversation.save(update_fields=["title", "updated_at"]) + + yield sse_event( + "done", + { + "assistant_message_id": assistant_message.pk, + "conversation_id": conversation.pk, + "title": conversation.title, + }, + ) + + +def build_conversation_title(content: str) -> str: + """Creates a concise title from the first user message.""" + + normalized = " ".join(content.strip().split()) + if not normalized: + return "新对话" + return normalized[:24] + + +def build_knowledge_context(content: str, *, n_results: int = 5) -> str: + """Formats global knowledge-base search hits for normal chat prompts.""" + + full_document_context = build_filename_matched_document_context(content) + if full_document_context: + return full_document_context + + try: + payload = search_knowledge_base(content, n_results=n_results) + except Exception as exc: + logger.warning("Knowledge-base search failed", extra={"error": str(exc)}) + return "" + if payload.get("error_message"): + return "" + results = [ + item + for item in _rank_knowledge_results(content, payload.get("results") or []) + if _is_relevant_knowledge_result(content, item) + ] + lines: list[str] = [] + for index, item in enumerate(results[:n_results], start=1): + text = " ".join(str(item.get("text") or "").split()) + if not text: + continue + source = str(item.get("source") or "未知来源") + score = item.get("score") + score_label = f",score={score:.4f}" if isinstance(score, (int, float)) else "" + lines.append(f"[{index}] 来源:{source}{score_label}\n{text[:1200]}") + return "\n\n".join(lines) + + +def should_refuse_ungrounded_chat( + conversation: Conversation, + content: str, + knowledge_context: str = "", +) -> bool: + if (knowledge_context or "").strip(): + return False + if _is_business_related_question(content): + return False + if _has_active_attachments(conversation): + return False + return True + + +def out_of_scope_reply() -> str: + return ( + "没有在当前启用的知识库材料中找到可依据的内容,且这个问题与当前主营业务无关。" + "为避免编造,我不能直接回答。请先上传或启用相关知识库材料,或改问体外诊断试剂注册资料审核、" + "文件汇总、法规核查、申报填表等业务范围内的问题。" + ) + + +def _is_business_related_question(content: str) -> bool: + normalized = (content or "").lower() + compact = "".join(normalized.split()) + if not compact: + return True + business_keywords = [ + "审核智能体", + "体外诊断", + "ivd", + "nmpa", + "cmde", + "医疗器械", + "注册资料", + "注册申报", + "注册检验", + "注册证", + "申报资料", + "申报文件", + "法规", + "核查", + "审评", + "审核", + "整改", + "风险", + "说明书", + "临床", + "性能", + "安全", + "适用范围", + "预期用途", + "附件", + "文件", + "压缩包", + "目录", + "页数", + "清单", + "汇总", + "模板", + "填表", + "知识库", + "检索", + "报告", + "材料", + "资料", + ] + return any(keyword in compact for keyword in business_keywords) + + +def build_filename_matched_document_context(query: str, *, max_chars: int = 12000) -> str: + terms = _knowledge_query_terms(query) + if not terms: + return "" + matches = [] + for document in KnowledgeBaseDocument.objects.filter( + status=KnowledgeBaseDocument.Status.ACTIVE, + is_active=True, + ).order_by("-updated_at", "-id"): + filename = f"{document.display_name} {document.original_name}" + if any(term and term in filename for term in terms): + matches.append(document) + if not matches: + return "" + lines = [ + "以下材料因用户问题中的关键词命中文档名称,已读取全文供回答前比对和总结。" + ] + for index, document in enumerate(matches[:3], start=1): + text = _extract_managed_document_text(document) + if not text: + continue + lines.append( + f"[全文材料 {index}] 来源:用户知识库/{document.original_name}\n" + f"{' '.join(text.split())[:max_chars]}" + ) + return "\n\n".join(lines).strip() + + +def _extract_managed_document_text(document: KnowledgeBaseDocument) -> str: + try: + return extract_text_from_path(Path(document.storage_path)) + except Exception as exc: + logger.warning( + "Managed document full-text extraction failed", + extra={"document_id": document.pk, "error": str(exc)}, + ) + return "" + + +def _rank_knowledge_results(query: str, results: list[dict[str, object]]) -> list[dict[str, object]]: + terms = [term for term in _knowledge_query_terms(query) if term] + + def sort_key(item: dict[str, object]) -> tuple[int, float]: + source = str(item.get("source") or "") + text = str(item.get("text") or "") + haystack = f"{source}\n{text}" + direct_hit = any(term in haystack for term in terms) + score = item.get("score") + numeric_score = float(score) if isinstance(score, (int, float)) else 999999.0 + return (0 if direct_hit else 1, numeric_score) + + return sorted(results, key=sort_key) + + +def _is_relevant_knowledge_result(query: str, item: dict[str, object]) -> bool: + terms = _knowledge_query_terms(query) + if not terms: + return False + source = str(item.get("source") or "") + text = str(item.get("text") or "") + haystack = f"{source}\n{text}" + if any(term in haystack for term in terms): + return True + metadata = item.get("metadata") or {} + if metadata.get("source_type") == "managed_document": + return True + return False + + +def _knowledge_query_terms(query: str) -> list[str]: + normalized = "".join((query or "").split()) + if not normalized: + return [] + stop_chars = set("是谁什么哪里如何怎么请问一下帮我你能告诉吗??,,。.") + compact = "".join(char for char in normalized if char not in stop_chars) + terms = [compact] if compact else [] + if normalized not in terms: + terms.append(normalized) + return terms + + +def _select_attachments_for_reader(conversation: Conversation, content: str): + attachments = list( + FileAttachment.objects.filter( + conversation=conversation, + is_active=True, + ) + .exclude(upload_status=FileAttachment.UploadStatus.DELETED) + .order_by("original_name", "-version_no") + ) + matched = [attachment for attachment in attachments if attachment.original_name in content] + return matched or attachments + + +def _has_active_attachments(conversation: Conversation) -> bool: + return ( + FileAttachment.objects.filter(conversation=conversation, is_active=True) + .exclude(upload_status=FileAttachment.UploadStatus.DELETED) + .exists() + ) + + +def _summary_covers_active_attachments(conversation: Conversation, batch: FileSummaryBatch) -> bool: + active_ids = set( + FileAttachment.objects.filter(conversation=conversation, is_active=True) + .exclude(upload_status=FileAttachment.UploadStatus.DELETED) + .values_list("id", flat=True) + ) + if not active_ids: + return True + bound_ids = set( + FileSummaryBatchAttachment.objects.filter(batch=batch).values_list("attachment_id", flat=True) + ) + return active_ids.issubset(bound_ids) + + +def _format_attachment_reader_reply(attachments: list[dict[str, object]], message: str) -> str: + if not attachments: + return message or "当前对话没有可读取的附件。" + + lines = ["## 附件解析结果"] + for item in attachments: + status = item.get("status", "") + filename = item.get("filename", "") + file_type = item.get("file_type", "") + lines.extend( + [ + "", + f"### {filename}", + f"- 类型:{file_type or '未知'}", + f"- 状态:{status}", + ] + ) + if item.get("error_message"): + lines.append(f"- 错误:{item['error_message']}") + continue + + preview = str(item.get("preview_text") or "").strip() + if preview: + lines.extend(["", "摘要预览:", "```text", preview, "```"]) + + sections = item.get("sections") or [] + if sections: + lines.append("") + lines.append("结构详情:") + for section in sections[:8]: + if not isinstance(section, dict): + continue + section_type = section.get("type", "section") + name = section.get("name", "") + extra = "" + if "row_count" in section: + extra = f",{section['row_count']} 行" + if "column_count" in section: + extra += f",{section['column_count']} 列" + lines.append(f"- {name}({section_type}{extra})") + return "\n".join(lines).strip() + + +def sse_event(event_name: str, payload: dict[str, object]) -> str: + """Formats one server-sent event frame.""" + + return f"event: {event_name}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" diff --git a/review_agent/skill_router.py b/review_agent/skill_router.py new file mode 100644 index 0000000..99d29c8 --- /dev/null +++ b/review_agent/skill_router.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass + +from .file_summary.workflow_trigger import ( + evaluate_attachment_reader_trigger, + evaluate_file_summary_trigger, +) +from .application_form_fill.constants import FORM_FILL_TRIGGER_KEYWORDS, WORKFLOW_TYPE as FORM_FILL_WORKFLOW_TYPE +from .llm import LLMConfigurationError, LLMRequestError, generate_completion +from .models import Conversation, FileAttachment +from .regulatory_info_package.constants import ( + REGULATORY_INFO_PACKAGE_TRIGGER_KEYWORDS, + WORKFLOW_TYPE as REGULATORY_INFO_PACKAGE_WORKFLOW_TYPE, +) + + +logger = logging.getLogger(__name__) + +ROUTE_ACTIONS = {"normal_chat", "attachment_reader", "file_summary"} +ROUTE_ACTIONS.add("regulatory_review") +ROUTE_ACTIONS.add(FORM_FILL_WORKFLOW_TYPE) +ROUTE_ACTIONS.add(REGULATORY_INFO_PACKAGE_WORKFLOW_TYPE) + + +@dataclass(frozen=True) +class SkillRoute: + action: str + skill_name: str = "" + workflow_type: str = "" + confidence: float = 0.0 + reason: str = "" + source: str = "llm" + + @property + def uses_attachment_reader(self) -> bool: + return self.action == "attachment_reader" + + @property + def starts_file_summary(self) -> bool: + return self.action == "file_summary" + + @property + def starts_regulatory_review(self) -> bool: + return self.action == "regulatory_review" + + @property + def starts_application_form_fill(self) -> bool: + return self.action == FORM_FILL_WORKFLOW_TYPE + + @property + def starts_regulatory_info_package(self) -> bool: + return self.action == REGULATORY_INFO_PACKAGE_WORKFLOW_TYPE + + @property + def is_normal_chat(self) -> bool: + return self.action == "normal_chat" + + +def route_message_intent(conversation: Conversation, content: str) -> SkillRoute: + deterministic_route = _deterministic_workflow_route(conversation, content) + if deterministic_route: + return deterministic_route + + attachments = list(_active_attachments(conversation)) + try: + route = _route_with_llm(conversation, content, attachments) + logger.info( + "LLM skill route selected", + extra={ + "conversation_id": conversation.pk, + "action": route.action, + "skill_name": route.skill_name, + "workflow_type": route.workflow_type, + "confidence": route.confidence, + "route_source": route.source, + "reason": route.reason, + }, + ) + return route + except (LLMConfigurationError, LLMRequestError, ValueError, json.JSONDecodeError) as exc: + logger.warning( + "LLM skill route failed, fallback to rules", + extra={"conversation_id": conversation.pk, "error": str(exc)}, + ) + return _route_with_rules(conversation, content) + + +def _deterministic_workflow_route(conversation: Conversation, content: str) -> SkillRoute | None: + if _matches_regulatory_info_package(content): + return SkillRoute( + action=REGULATORY_INFO_PACKAGE_WORKFLOW_TYPE, + workflow_type=REGULATORY_INFO_PACKAGE_WORKFLOW_TYPE, + confidence=0.9, + reason="命中明确第1章监管信息材料包生成关键词。", + source="rule_preflight", + ) + if _matches_application_form_fill(content): + return SkillRoute( + action=FORM_FILL_WORKFLOW_TYPE, + workflow_type=FORM_FILL_WORKFLOW_TYPE, + confidence=0.9, + reason="命中明确申报文件自动填表关键词。", + source="rule_preflight", + ) + if _matches_regulatory_review(content): + return SkillRoute( + action="regulatory_review", + workflow_type="regulatory_review", + confidence=0.9, + reason="命中明确法规核查关键词。", + source="rule_preflight", + ) + file_summary = evaluate_file_summary_trigger(conversation, content) + if file_summary.should_start or file_summary.reason == "missing_attachment": + return SkillRoute( + action="file_summary", + workflow_type="file_summary", + confidence=0.8, + reason=file_summary.reason, + source="rule_preflight", + ) + return None + + +def _route_with_llm( + conversation: Conversation, + content: str, + attachments: list[FileAttachment], +) -> SkillRoute: + raw = generate_completion( + [ + {"role": "system", "content": _router_system_prompt()}, + { + "role": "user", + "content": _router_user_prompt( + user_message=content, + attachments=attachments, + ), + }, + ], + temperature=0.0, + ) + payload = _parse_json_object(raw) + action = str(payload.get("action", "normal_chat")).strip() + if action not in ROUTE_ACTIONS: + raise ValueError(f"不支持的路由动作:{action}") + + if action in {"attachment_reader", "file_summary"} and not attachments: + return SkillRoute( + action=action, + skill_name="attachment_reader" if action == "attachment_reader" else "", + workflow_type="file_summary" if action == "file_summary" else "", + confidence=_float_or_zero(payload.get("confidence")), + reason=str(payload.get("reason") or "LLM 判断需要附件,但当前无附件。"), + source="llm_missing_attachment", + ) + + return SkillRoute( + action=action, + skill_name="attachment_reader" if action == "attachment_reader" else "", + workflow_type=action + if action in {"file_summary", "regulatory_review", FORM_FILL_WORKFLOW_TYPE, REGULATORY_INFO_PACKAGE_WORKFLOW_TYPE} + else "", + confidence=_float_or_zero(payload.get("confidence")), + reason=str(payload.get("reason") or ""), + source="llm", + ) + + +def _route_with_rules(conversation: Conversation, content: str) -> SkillRoute: + if _matches_regulatory_info_package(content): + return SkillRoute( + action=REGULATORY_INFO_PACKAGE_WORKFLOW_TYPE, + workflow_type=REGULATORY_INFO_PACKAGE_WORKFLOW_TYPE, + confidence=0.7, + reason="命中第1章监管信息材料包生成关键词。", + source="rule_fallback", + ) + + if _matches_application_form_fill(content): + return SkillRoute( + action=FORM_FILL_WORKFLOW_TYPE, + workflow_type=FORM_FILL_WORKFLOW_TYPE, + confidence=0.7, + reason="命中申报文件自动填表关键词。", + source="rule_fallback", + ) + + if _matches_regulatory_review(content): + return SkillRoute( + action="regulatory_review", + workflow_type="regulatory_review", + confidence=0.7, + reason="命中法规核查关键词。", + source="rule_fallback", + ) + + file_summary = evaluate_file_summary_trigger(conversation, content) + if file_summary.should_start or file_summary.reason == "missing_attachment": + return SkillRoute( + action="file_summary", + workflow_type="file_summary", + confidence=0.5, + reason=file_summary.reason, + source="rule_fallback", + ) + + attachment_reader = evaluate_attachment_reader_trigger(conversation, content) + if attachment_reader.should_start or attachment_reader.reason == "missing_attachment": + return SkillRoute( + action="attachment_reader", + skill_name="attachment_reader", + confidence=0.5, + reason=attachment_reader.reason, + source="rule_fallback", + ) + + return SkillRoute( + action="normal_chat", + confidence=0.5, + reason="未匹配到需要调用 Skill 或工作流的意图。", + source="rule_fallback", + ) + + +def _active_attachments(conversation: Conversation): + return ( + FileAttachment.objects.filter(conversation=conversation, is_active=True) + .exclude(upload_status=FileAttachment.UploadStatus.DELETED) + .order_by("original_name", "-version_no") + ) + + +def _router_system_prompt() -> str: + return ( + "你是审核智能体的工具路由器,只判断是否需要调用工具,不直接回答用户。" + "你必须只输出 JSON 对象,不要输出 Markdown。" + "可选 action:normal_chat、attachment_reader、file_summary、regulatory_review、application_form_fill、regulatory_info_package。" + "attachment_reader 用于用户要求阅读、提取、分析、总结、查看上传附件内容。" + "file_summary 用于用户要求自动汇总文件目录、页数、清单或生成目录页数报告。" + "regulatory_review 用于用户要求法规核查、NMPA核查、完整性核查、章节一致性核查、风险预警或整改建议。" + "application_form_fill 用于用户要求填注册证、生成申报模板、填写对应表格、安全和性能基本原则清单或自动填表。" + "regulatory_info_package 用于用户要求根据说明书生成第1章监管信息、监管信息材料包、申请表、产品列表或声明材料包。" + "normal_chat 用于不需要读取附件或执行工作流的一般问答。" + "输出字段:action、confidence、reason。" + ) + + +def _router_user_prompt(*, user_message: str, attachments: list[FileAttachment]) -> str: + attachment_lines = [ + f"- id={attachment.pk}, name={attachment.original_name}, active={attachment.is_active}, status={attachment.upload_status}" + for attachment in attachments + ] + attachment_text = "\n".join(attachment_lines) if attachment_lines else "无 active 附件" + return ( + f"用户消息:{user_message}\n\n" + f"当前 active 附件:\n{attachment_text}\n\n" + "请判断应调用哪个 action。只输出 JSON。" + ) + + +def _parse_json_object(raw: str) -> dict: + text = (raw or "").strip() + if text.startswith("```"): + text = text.strip("`").strip() + if text.lower().startswith("json"): + text = text[4:].strip() + start = text.find("{") + end = text.rfind("}") + if start == -1 or end == -1 or end < start: + raise json.JSONDecodeError("未找到 JSON 对象", text, 0) + return json.loads(text[start : end + 1]) + + +def _float_or_zero(value) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _matches_regulatory_review(content: str) -> bool: + normalized = content.lower() + keywords = [ + "法规核查", + "nmpa核查", + "nmpa 核查", + "完整性核查", + "风险预警", + "整改建议", + "章节核查", + "一致性核查", + ] + return any(keyword in normalized for keyword in keywords) + + +def _matches_regulatory_info_package(content: str) -> bool: + normalized = "".join((content or "").lower().split()) + return any("".join(keyword.lower().split()) in normalized for keyword in REGULATORY_INFO_PACKAGE_TRIGGER_KEYWORDS) + + +def _matches_application_form_fill(content: str) -> bool: + normalized = content.lower() + return any(keyword.lower() in normalized for keyword in FORM_FILL_TRIGGER_KEYWORDS) diff --git a/review_agent/urls.py b/review_agent/urls.py new file mode 100644 index 0000000..59aa2c1 --- /dev/null +++ b/review_agent/urls.py @@ -0,0 +1,159 @@ +from django.urls import path + +from .file_summary.views import ( + attachment_download, + attachment_detail, + attachments, + batch_events, + batch_status, + conversation_detail, + conversation_list, + conversation_messages, + export_download, +) +from .regulatory_review.views import ( + batch_status as regulatory_review_batch_status, + confirm_conditions as regulatory_review_confirm_conditions, + review_issues as regulatory_review_review_issues, + start_full_review as regulatory_review_start_full_review, +) +from .application_form_fill.views import ( + batch_status as application_form_fill_batch_status, + start as application_form_fill_start, +) +from .regulatory_info_package.views import ( + batch_status as regulatory_info_package_batch_status, + start as regulatory_info_package_start, +) +from .views import ( + knowledge_base_document_detail, + knowledge_base_document_index, + knowledge_base_documents, + knowledge_base_rebuild_index, + knowledge_base_search, + knowledge_base_status, +) + + +urlpatterns = [ + path( + "api/review-agent/conversations/", + conversation_list, + name="review_agent_conversation_list", + ), + path( + "api/review-agent/conversations//", + conversation_detail, + name="review_agent_conversation_detail", + ), + path( + "api/review-agent/conversations//attachments/", + attachments, + name="file_summary_attachment_upload", + ), + path( + "api/review-agent/conversations//attachments/", + attachments, + name="file_summary_attachment_list", + ), + path( + "api/review-agent/conversations//attachments//", + attachment_detail, + name="file_summary_attachment_detail", + ), + path( + "api/review-agent/conversations//attachments//download/", + attachment_download, + name="file_summary_attachment_download", + ), + path( + "api/review-agent/conversations//messages/", + conversation_messages, + name="review_agent_conversation_messages", + ), + path( + "api/review-agent/file-summary//status/", + batch_status, + name="file_summary_batch_status", + ), + path( + "api/review-agent/file-summary//events/", + batch_events, + name="file_summary_batch_events", + ), + path( + "api/review-agent/file-summary/exports//download/", + export_download, + name="file_summary_export_download", + ), + path( + "api/review-agent/regulatory-review//status/", + regulatory_review_batch_status, + name="regulatory_review_batch_status", + ), + path( + "api/review-agent/regulatory-review//conditions/", + regulatory_review_confirm_conditions, + name="regulatory_review_confirm_conditions", + ), + path( + "api/review-agent/regulatory-review//full-review/", + regulatory_review_start_full_review, + name="regulatory_review_start_full_review", + ), + path( + "api/review-agent/regulatory-review//issue-review/", + regulatory_review_review_issues, + name="regulatory_review_review_issues", + ), + path( + "api/review-agent/application-form-fill/start/", + application_form_fill_start, + name="application_form_fill_start", + ), + path( + "api/review-agent/application-form-fill//status/", + application_form_fill_batch_status, + name="application_form_fill_batch_status", + ), + path( + "api/review-agent/regulatory-info-package/start/", + regulatory_info_package_start, + name="regulatory_info_package_start", + ), + path( + "api/review-agent/regulatory-info-package//status/", + regulatory_info_package_batch_status, + name="regulatory_info_package_batch_status", + ), + path( + "api/review-agent/knowledge-base/status/", + knowledge_base_status, + name="knowledge_base_status", + ), + path( + "api/review-agent/knowledge-base/search/", + knowledge_base_search, + name="knowledge_base_search", + ), + path( + "api/review-agent/knowledge-base/rebuild-index/", + knowledge_base_rebuild_index, + name="knowledge_base_rebuild_index", + ), + path( + "api/review-agent/knowledge-base/documents/", + knowledge_base_documents, + name="knowledge_base_document_list", + ), + path( + "api/review-agent/knowledge-base/documents//", + knowledge_base_document_detail, + name="knowledge_base_document_detail", + ), + path( + "api/review-agent/knowledge-base/documents//index/", + knowledge_base_document_index, + name="knowledge_base_document_index", + ), +] diff --git a/review_agent/views.py b/review_agent/views.py new file mode 100644 index 0000000..5613cdd --- /dev/null +++ b/review_agent/views.py @@ -0,0 +1,551 @@ +from django.contrib.auth.decorators import login_required +from django.conf import settings +from django.db.models import Count, Q, Sum +import json +from pathlib import Path + +from django.http import HttpRequest, HttpResponse, JsonResponse, StreamingHttpResponse +from django.shortcuts import redirect, render +from django.utils.http import urlencode +from django.views.decorators.http import require_http_methods + +from .services import ( + create_conversation, + get_conversation_for_user, + list_conversations, + send_message, + stream_message, +) +from .models import ( + ApplicationFormFillBatch, + Conversation, + FileAttachment, + FileSummaryBatch, + RegulatoryInfoPackageBatch, + RegulatoryReviewBatch, + WorkflowNodeRun, +) +from .knowledge_base import build_knowledge_base_context, search_knowledge_base +from .knowledge_base import ( + build_knowledge_base_context_for_user, + create_document_from_upload, + delete_document, + index_managed_document, + list_documents_for_user, + serialize_document, + update_document, +) +from .models import KnowledgeBaseDocument +from .regulatory_review.services.info_extract import ensure_regulatory_condition_candidates +from .regulatory_review.services.rag_embedding import get_embedding_provider +from .regulatory_review.services.rag_index import build_chroma_index +from .regulatory_review.services.rule_loader import load_rule_file + + +@login_required +@require_http_methods(["GET"]) +def home_dashboard(request: HttpRequest) -> HttpResponse: + """Renders the data-first home dashboard for the current user.""" + + if request.GET.get("conversation"): + query = {"conversation": request.GET["conversation"]} + search = (request.GET.get("q") or "").strip() + if search: + query["q"] = search + return redirect(f"/chat/?{urlencode(query)}") + + context = build_home_dashboard_context(request.user) + return render( + request, + "workbench.html", + { + "page_title": "首页", + "dashboard": context, + }, + ) + + +@login_required +@require_http_methods(["GET", "POST"]) +def workspace(request: HttpRequest) -> HttpResponse: + """Renders the review-agent workspace and handles conversation actions.""" + + if request.method == "POST": + action = request.POST.get("action") + conversation = get_conversation_for_user(request.user, request.POST.get("conversation_id")) + + if action == "new_conversation": + conversation = create_conversation(request.user) + return redirect(f"/chat/?conversation={conversation.pk}") + + if action == "send_message": + content = (request.POST.get("prompt") or "").strip() + if not conversation: + conversation = create_conversation(request.user) + if content: + send_message(conversation, content) + return redirect(f"/chat/?conversation={conversation.pk}") + + search = (request.GET.get("q") or "").strip() + conversations = list_conversations(request.user, search) + current = get_conversation_for_user(request.user, request.GET.get("conversation")) + + if current is None and conversations.exists(): + current = conversations.first() + + workflow_cards = build_workflow_cards(current) if current else [] + condition_confirmation = build_condition_confirmation(workflow_cards) + + return render( + request, + "home.html", + { + "page_title": "审核智能体", + "search_query": search, + "conversations": conversations, + "current_conversation": current, + "messages": current.messages.all() if current else [], + "attachments": FileAttachment.objects.filter(conversation=current).order_by("original_name", "-version_no") if current else [], + "workflow_cards": workflow_cards, + "condition_confirmation": condition_confirmation, + }, + ) + + +@login_required +@require_http_methods(["GET"]) +def attachment_manager(request: HttpRequest) -> HttpResponse: + conversations = ( + Conversation.objects.filter(user=request.user) + .annotate( + attachment_count=Count( + "file_attachments", + filter=~Q(file_attachments__upload_status=FileAttachment.UploadStatus.DELETED), + ) + ) + .order_by("-updated_at", "-id") + ) + selected = get_conversation_for_user(request.user, request.GET.get("conversation")) + attachments = ( + FileAttachment.objects.filter(conversation=selected) + .order_by("original_name", "-version_no") + if selected + else [] + ) + return render( + request, + "attachment_manager.html", + { + "page_title": "附件管理", + "conversations": conversations, + "selected_conversation": selected, + "attachments": attachments, + }, + ) + + +@login_required +@require_http_methods(["GET"]) +def knowledge_base_manager(request: HttpRequest) -> HttpResponse: + context = build_knowledge_base_context_for_user(request.user) + return render( + request, + "knowledge_base.html", + { + "page_title": "知识库管理", + "knowledge_base": context, + }, + ) + + +@login_required +@require_http_methods(["GET"]) +def knowledge_base_status(request: HttpRequest) -> JsonResponse: + return JsonResponse(build_knowledge_base_context_for_user(request.user)) + + +@login_required +@require_http_methods(["POST"]) +def knowledge_base_rebuild_index(request: HttpRequest) -> JsonResponse: + payload = rebuild_knowledge_base_index() + return JsonResponse({"knowledge_base": build_knowledge_base_context_for_user(request.user), **payload}) + + +def rebuild_knowledge_base_index() -> dict[str, object]: + rule_set = load_rule_file() + source_dir = Path(settings.BASE_DIR) / rule_set["source_material_dir"] + chunk_count = build_chroma_index( + source_dir=source_dir, + embedding_provider=get_embedding_provider(), + reset=True, + ) + return {"chunk_count": chunk_count} + + +@login_required +@require_http_methods(["POST"]) +def knowledge_base_search(request: HttpRequest) -> JsonResponse: + if request.content_type == "application/json": + try: + payload = json.loads(request.body.decode("utf-8") or "{}") + except json.JSONDecodeError: + payload = {} + query = payload.get("query", "") + else: + query = request.POST.get("query", "") + return JsonResponse(search_knowledge_base(str(query))) + + +@login_required +@require_http_methods(["GET", "POST"]) +def knowledge_base_documents(request: HttpRequest) -> JsonResponse: + if request.method == "GET": + return JsonResponse({"documents": list_documents_for_user(request.user)}) + uploaded_file = request.FILES.get("file") + if uploaded_file is None: + return JsonResponse({"error": "请上传知识库材料。"}, status=400) + is_active = str(request.POST.get("is_active", "true")).lower() not in {"0", "false", "off"} + document = create_document_from_upload( + user=request.user, + uploaded_file=uploaded_file, + display_name=request.POST.get("display_name", ""), + description=request.POST.get("description", ""), + is_active=is_active, + ) + return JsonResponse({"document": serialize_document(document)}) + + +@login_required +@require_http_methods(["GET", "PATCH", "DELETE"]) +def knowledge_base_document_detail(request: HttpRequest, document_id: int) -> JsonResponse: + try: + document = KnowledgeBaseDocument.objects.get( + pk=document_id, + user=request.user, + ) + except KnowledgeBaseDocument.DoesNotExist: + return JsonResponse({"error": "知识库材料不存在。"}, status=404) + if document.status == KnowledgeBaseDocument.Status.DELETED: + return JsonResponse({"error": "知识库材料不存在。"}, status=404) + if request.method == "GET": + return JsonResponse({"document": serialize_document(document)}) + if request.method == "DELETE": + delete_document(document) + return JsonResponse({"document": serialize_document(document)}) + try: + payload = json.loads(request.body.decode("utf-8") or "{}") + except json.JSONDecodeError: + payload = {} + update_document(document, payload) + return JsonResponse({"document": serialize_document(document)}) + + +@login_required +@require_http_methods(["POST"]) +def knowledge_base_document_index(request: HttpRequest, document_id: int) -> JsonResponse: + try: + document = KnowledgeBaseDocument.objects.get( + pk=document_id, + user=request.user, + ) + except KnowledgeBaseDocument.DoesNotExist: + return JsonResponse({"error": "知识库材料不存在。"}, status=404) + if document.status == KnowledgeBaseDocument.Status.DELETED: + return JsonResponse({"error": "知识库材料不存在。"}, status=404) + chunk_count = index_managed_document(document) + document.refresh_from_db() + return JsonResponse({"document": serialize_document(document), "chunk_count": chunk_count}) + + +@login_required +@require_http_methods(["POST"]) +def stream_chat(request: HttpRequest) -> HttpResponse: + """Streams one assistant reply so the UI can render incremental output.""" + + content = (request.POST.get("prompt") or "").strip() + if not content: + return JsonResponse({"error": "消息内容不能为空。"}, status=400) + + conversation = get_conversation_for_user(request.user, request.POST.get("conversation_id")) + if not conversation: + conversation = create_conversation(request.user) + + response = StreamingHttpResponse( + streaming_content=stream_message(conversation, content), + content_type="text/event-stream", + ) + response["Cache-Control"] = "no-cache" + response["X-Accel-Buffering"] = "no" + return response + + +def build_workflow_cards(conversation: Conversation) -> list[dict[str, object]]: + cards: list[dict[str, object]] = [] + for batch in FileSummaryBatch.objects.filter(conversation=conversation).prefetch_related("node_runs"): + cards.append( + { + "id": batch.pk, + "workflow_type": "file_summary", + "batch_no": batch.batch_no, + "status": batch.status, + "error_message": batch.error_message, + "risk_label": "", + "created_at": batch.created_at, + "nodes": list(batch.node_runs.order_by("id")), + } + ) + regulatory_batches = RegulatoryReviewBatch.objects.filter(conversation=conversation) + for batch in regulatory_batches: + condition_candidates = ensure_regulatory_condition_candidates(batch) + cards.append( + { + "id": batch.pk, + "workflow_type": "regulatory_review", + "batch_no": batch.batch_no, + "status": batch.status, + "error_message": batch.error_message, + "risk_label": _format_risk_label(batch.risk_summary or {}), + "condition_json": batch.condition_json or {}, + "condition_candidates": condition_candidates, + "notification_count": batch.notifications.count(), + "review_record_count": batch.artifacts.filter(metadata__artifact="review_record").count(), + "created_at": batch.created_at, + "nodes": list( + WorkflowNodeRun.objects.filter( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + ).order_by("id") + ), + } + ) + form_fill_batches = ApplicationFormFillBatch.objects.filter(conversation=conversation, is_deleted=False) + for batch in form_fill_batches: + cards.append( + { + "id": batch.pk, + "workflow_type": "application_form_fill", + "batch_no": batch.batch_no, + "status": batch.status, + "error_message": batch.error_message, + "risk_label": _format_form_fill_label(batch), + "created_at": batch.created_at, + "nodes": list( + WorkflowNodeRun.objects.filter( + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + ).order_by("id") + ), + } + ) + rip_batches = RegulatoryInfoPackageBatch.objects.filter(conversation=conversation, is_deleted=False) + for batch in rip_batches: + cards.append( + { + "id": batch.pk, + "workflow_type": "regulatory_info_package", + "batch_no": batch.batch_no, + "status": batch.status, + "error_message": batch.error_message, + "risk_label": _format_regulatory_info_package_label(batch), + "created_at": batch.created_at, + "nodes": list( + WorkflowNodeRun.objects.filter( + workflow_type="regulatory_info_package", + workflow_batch_id=batch.pk, + ).order_by("id") + ), + } + ) + return sorted(cards, key=lambda item: item["created_at"], reverse=True)[:5] + + +def build_condition_confirmation(workflow_cards: list[dict[str, object]]) -> dict[str, object] | None: + for card in workflow_cards: + if ( + card.get("workflow_type") == "regulatory_review" + and card.get("status") == RegulatoryReviewBatch.Status.WAITING_USER + and card.get("condition_candidates") + ): + return { + "id": card["id"], + "batch_no": card["batch_no"], + "candidates": card["condition_candidates"], + } + return None + + +def _format_risk_label(risk_summary: dict) -> str: + parts = [] + labels = [ + ("blocking", "阻断项"), + ("high", "高风险"), + ("medium", "中风险"), + ("low", "低风险"), + ("info", "提示"), + ] + for key, label in labels: + count = int(risk_summary.get(key) or 0) + if count: + parts.append(f"{label} {count}") + return " · ".join(parts) + + +def _format_form_fill_label(batch: ApplicationFormFillBatch) -> str: + parts = [] + if batch.selected_templates: + parts.append("模板 " + "、".join(batch.selected_templates)) + if batch.conflict_summary: + parts.append(f"冲突字段 {len(batch.conflict_summary)}") + if batch.risk_notes: + parts.append(f"提示 {len(batch.risk_notes)}") + return " · ".join(parts) + + +def _format_regulatory_info_package_label(batch: RegulatoryInfoPackageBatch) -> str: + parts = [] + if batch.product_name: + parts.append(batch.product_name) + if batch.generated_files: + success_count = sum(1 for item in batch.generated_files if item.get("status") in {"success", "fallback_success"}) + parts.append(f"生成 {success_count}/7") + if batch.missing_fields: + parts.append(f"缺失 {len(batch.missing_fields)}") + if batch.conflict_fields: + parts.append(f"冲突 {len(batch.conflict_fields)}") + return " · ".join(parts) + + +def build_home_dashboard_context(user) -> dict[str, object]: + conversations = Conversation.objects.filter(user=user) + active_attachments = FileAttachment.objects.filter(user=user).exclude( + upload_status=FileAttachment.UploadStatus.DELETED + ) + active_knowledge_documents = KnowledgeBaseDocument.objects.filter(user=user).exclude( + status=KnowledgeBaseDocument.Status.DELETED + ) + knowledge_context = build_knowledge_base_context_for_user(user) + builtin_source_count = int(knowledge_context.get("source_count") or 0) + collection_chunk_count = int((knowledge_context.get("collection") or {}).get("count") or 0) + managed_document_count = active_knowledge_documents.count() + file_batches = FileSummaryBatch.objects.filter(user=user).select_related("conversation") + regulatory_batches = RegulatoryReviewBatch.objects.filter(user=user).select_related("conversation") + form_fill_batches = ApplicationFormFillBatch.objects.filter(user=user, is_deleted=False).select_related("conversation") + + batch_status_counts = _build_batch_status_counts(file_batches, regulatory_batches, form_fill_batches) + total_batches = file_batches.count() + regulatory_batches.count() + form_fill_batches.count() + successful_batches = batch_status_counts["success"] + handled_batches = successful_batches + batch_status_counts["failed"] + recent_records = _build_recent_dashboard_records( + conversations.order_by("-updated_at", "-id")[:8], + file_batches.order_by("-created_at", "-id")[:8], + regulatory_batches.order_by("-created_at", "-id")[:8], + form_fill_batches.order_by("-created_at", "-id")[:8], + ) + + return { + "metrics": { + "conversation_count": conversations.count(), + "recent_conversation_count": conversations.filter(messages__isnull=False).distinct().count(), + "attachment_count": active_attachments.count(), + "active_attachment_count": active_attachments.filter(is_active=True).count(), + "knowledge_document_count": managed_document_count + builtin_source_count, + "running_batch_count": batch_status_counts["running"], + "handled_batch_count": handled_batches, + "success_batch_count": successful_batches, + "waiting_batch_count": batch_status_counts["waiting"], + "failed_batch_count": batch_status_counts["failed"], + "total_batch_count": total_batches, + }, + "knowledge": { + "document_count": managed_document_count, + "builtin_source_count": builtin_source_count, + "total_material_count": managed_document_count + builtin_source_count, + "active_document_count": active_knowledge_documents.filter(is_active=True).count(), + "indexed_document_count": active_knowledge_documents.filter(indexed_chunk_count__gt=0).count(), + "managed_chunk_count": active_knowledge_documents.aggregate(total=Sum("indexed_chunk_count"))["total"] or 0, + "chunk_count": collection_chunk_count, + }, + "attachments": { + "attachment_count": active_attachments.count(), + "active_attachment_count": active_attachments.filter(is_active=True).count(), + "recent_attachment_count": active_attachments.order_by("-created_at", "-id")[:5].count(), + "conversation_count": active_attachments.values("conversation_id").distinct().count(), + }, + "workflow": { + "file_summary_count": file_batches.count(), + "regulatory_review_count": regulatory_batches.count(), + "application_form_fill_count": form_fill_batches.count(), + **batch_status_counts, + }, + "recent_records": recent_records, + } + + +def _build_batch_status_counts(file_batches, regulatory_batches, form_fill_batches) -> dict[str, int]: + running_statuses = { + FileSummaryBatch.Status.PENDING, + FileSummaryBatch.Status.RUNNING, + ApplicationFormFillBatch.Status.PENDING, + ApplicationFormFillBatch.Status.RUNNING, + RegulatoryReviewBatch.Status.PENDING, + RegulatoryReviewBatch.Status.RUNNING, + } + waiting_statuses = { + ApplicationFormFillBatch.Status.WAITING_USER, + RegulatoryReviewBatch.Status.WAITING_USER, + } + success_statuses = { + FileSummaryBatch.Status.SUCCESS, + RegulatoryReviewBatch.Status.SUCCESS, + ApplicationFormFillBatch.Status.SUCCESS, + ApplicationFormFillBatch.Status.PARTIAL_SUCCESS, + } + failed_statuses = { + FileSummaryBatch.Status.FAILED, + RegulatoryReviewBatch.Status.FAILED, + ApplicationFormFillBatch.Status.FAILED, + } + statuses = [ + *file_batches.values_list("status", flat=True), + *regulatory_batches.values_list("status", flat=True), + *form_fill_batches.values_list("status", flat=True), + ] + return { + "running": sum(1 for status in statuses if status in running_statuses), + "waiting": sum(1 for status in statuses if status in waiting_statuses), + "success": sum(1 for status in statuses if status in success_statuses), + "failed": sum(1 for status in statuses if status in failed_statuses), + } + + +def _build_recent_dashboard_records(conversations, file_batches, regulatory_batches, form_fill_batches) -> list[dict[str, object]]: + records = [] + for conversation in conversations: + records.append( + { + "type": "对话", + "title": conversation.title or "新对话", + "status": "已更新", + "updated_at": conversation.updated_at, + "url": f"/chat/?conversation={conversation.pk}", + } + ) + for batch in file_batches: + records.append(_batch_record(batch, "文件汇总")) + for batch in regulatory_batches: + status = batch.status + risk_label = _format_risk_label(batch.risk_summary or {}) + records.append(_batch_record(batch, "法规核查", status_label=risk_label or status)) + for batch in form_fill_batches: + records.append(_batch_record(batch, "申报填表")) + return sorted(records, key=lambda item: item["updated_at"], reverse=True)[:8] + + +def _batch_record(batch, record_type: str, status_label: str | None = None) -> dict[str, object]: + return { + "type": record_type, + "title": batch.batch_no, + "status": status_label or batch.status, + "updated_at": batch.created_at, + "url": f"/chat/?conversation={batch.conversation_id}", + } diff --git a/static/css/login.css b/static/css/login.css new file mode 100644 index 0000000..b7fa671 --- /dev/null +++ b/static/css/login.css @@ -0,0 +1,2433 @@ +:root { + color-scheme: light; + --bg: #f5f7fb; + --bg-strong: #edf2f7; + --panel: #ffffff; + --panel-soft: #f9fbff; + --panel-muted: #eef4ff; + --text: #1f2a37; + --muted: #7b8794; + --line: #e5eaf1; + --line-strong: #d7e0ea; + --accent: #3a72d8; + --accent-dark: #2f5fbb; + --sidebar: #f7faff; + --sidebar-strong: #eef4fb; + --danger-bg: #fff1f2; + --danger-text: #be123c; + --shadow: 0 12px 32px rgba(31, 42, 55, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + min-height: 100vh; + margin: 0; + background: linear-gradient(180deg, #fbfcfe 0%, #f5f8fc 100%); + color: var(--text); + font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; +} + +.login-page, +.shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px 16px; +} + +.login-card, +.panel { + width: min(100%, 420px); + padding: 32px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.eyebrow { + margin: 0 0 10px; + color: var(--accent); + font-size: 13px; + font-weight: 700; + letter-spacing: 0; +} + +h1 { + margin: 0; + font-size: 28px; + line-height: 1.25; +} + +.muted { + margin: 10px 0 24px; + color: var(--muted); + line-height: 1.7; +} + +.alert { + margin-bottom: 18px; + padding: 12px 14px; + border-radius: 8px; + background: var(--danger-bg); + color: var(--danger-text); + font-size: 14px; +} + +form { + display: grid; + gap: 12px; +} + +label { + color: #344054; + font-size: 14px; + font-weight: 600; +} + +input[type="text"], +input[type="password"] { + width: 100%; + height: 44px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--text); + font: inherit; +} + +input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.14); + outline: none; +} + +.button { + height: 44px; + margin-top: 8px; + border: 0; + border-radius: 8px; + background: var(--accent); + color: #ffffff; + cursor: pointer; + font: inherit; + font-weight: 700; +} + +.button:hover { + background: var(--accent-dark); +} + +.app-body { + overflow: hidden; +} + +.app-shell { + display: grid; + grid-template-rows: 60px minmax(0, 1fr); + height: 100vh; + min-height: 0; + background: var(--bg); +} + +.workspace { + display: grid; + grid-template-columns: 296px minmax(0, 1fr) 340px; + min-height: 0; + height: 100%; + overflow: hidden; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 24px; + padding: 18px; + min-height: 0; + overflow: hidden; + background: linear-gradient(180deg, var(--sidebar) 0%, var(--sidebar-strong) 100%); + border-right: 1px solid var(--line); + transition: width 180ms ease, padding 180ms ease, transform 180ms ease; +} + +.sidebar-top { + display: grid; + gap: 14px; +} + +.sidebar-header { + display: flex; + align-items: center; + gap: 12px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + color: var(--text); +} + +.brand-copy { + display: grid; + gap: 2px; +} + +.brand-mark, +.avatar, +.message-avatar { + display: inline-grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 11px; + background: #e4edff; + color: var(--accent); + font-size: 14px; + font-weight: 700; +} + +.brand-text { + font-size: 14px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.brand-subtitle { + color: var(--muted); + font-size: 12px; +} + +.icon-button { + display: inline-flex; + flex-direction: column; + justify-content: center; + gap: 5px; + width: 40px; + height: 40px; + border: 1px solid var(--line); + border-radius: 10px; + background: #ffffff; + cursor: pointer; +} + +.icon-button span { + width: 16px; + height: 2px; + margin-left: 11px; + border-radius: 999px; + background: #6b7785; +} + +.new-chat, +.ghost-button, +.send-button, +.tool-chip { + border: 0; + border-radius: 12px; + font: inherit; + cursor: pointer; +} + +.new-chat { + height: 44px; + width: 100%; + background: var(--accent); + color: #ffffff; + font-weight: 700; +} + +.search-form input { + width: 100%; + height: 38px; + padding: 0 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: #ffffff; + color: var(--text); + font: inherit; +} + +.sidebar-label { + margin: 0 0 10px; + color: var(--muted); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.sidebar-group { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; +} + +.history-list { + display: grid; + align-content: start; + gap: 8px; + min-height: 0; + overflow-y: auto; + padding-right: 4px; + scrollbar-width: thin; + scrollbar-color: #c4cfdd transparent; +} + +.history-list::-webkit-scrollbar { + width: 8px; +} + +.history-list::-webkit-scrollbar-track { + background: transparent; +} + +.history-list::-webkit-scrollbar-thumb { + border-radius: 999px; + background: #c4cfdd; +} + +.history-item { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) 28px; + align-items: center; + gap: 8px; + padding: 10px 8px 10px 14px; + border: 1px solid var(--line); + border-radius: 14px; + color: var(--text); + background: rgba(255, 255, 255, 0.82); +} + +.history-item.active, +.history-item:hover { + border-color: #cfdcf6; + background: #edf4ff; +} + +.history-link { + display: grid; + min-width: 0; + gap: 4px; + color: inherit; + text-decoration: none; +} + +.history-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + font-weight: 600; +} + +.history-meta { + color: var(--muted); + font-size: 12px; +} + +.history-item .history-delete { + appearance: none; + -webkit-appearance: none; + display: inline-grid; + place-items: center; + flex: 0 0 28px; + width: 28px; + min-width: 28px; + height: 28px; + min-height: 28px; + padding: 0; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: #93a0af; + cursor: pointer; + font: inherit; + font-size: 18px; + line-height: 1; + opacity: 0; + transition: opacity 140ms ease, background 140ms ease, color 140ms ease, border-color 140ms ease; +} + +.history-item:hover .history-delete, +.history-item.active .history-delete, +.history-item .history-delete:focus-visible { + opacity: 1; +} + +.history-item .history-delete:hover { + border-color: #fecdd3; + background: var(--danger-bg); + color: var(--danger-text); +} + +.history-empty { + padding: 16px 14px; + border: 1px dashed var(--line-strong); + border-radius: 14px; + background: #ffffff; +} + +.history-empty p, +.history-empty span { + margin: 0; +} + +.history-empty span { + display: block; + margin-top: 6px; + color: var(--muted); + font-size: 12px; +} + +.user-card, +.user-menu-trigger, +.user-copy { + display: grid; + gap: 2px; +} + +.user-copy strong { + font-size: 14px; +} + +.user-copy span { + color: var(--muted); + font-size: 12px; +} + +.chat-shell { + display: grid; + grid-template-rows: minmax(0, 1fr); + min-width: 0; + min-height: 0; + padding: 0; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 24px; + min-height: 60px; + position: relative; + z-index: 30; + border-bottom: 1px solid var(--line); + background: #ffffff; +} + +.topbar-left, +.topbar-right { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.mobile-toggle { + display: none; +} + +.tabbar { + display: inline-flex; + align-items: center; + gap: 0; + height: 60px; +} + +.tab { + display: inline-flex; + align-items: center; + height: 60px; + padding: 0 20px; + border: 0; + background: transparent; + color: var(--muted); + cursor: pointer; + font: inherit; + font-weight: 600; + border-bottom: 2px solid transparent; + text-decoration: none; +} + +.tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.avatar.large { + width: 40px; + height: 40px; +} + +.user-menu { + position: relative; +} + +.user-menu-trigger { + display: flex; + align-items: center; + gap: 12px; + min-height: 44px; + padding: 6px 12px; + border: 1px solid transparent; + border-radius: 12px; + background: #ffffff; + color: var(--text); + cursor: pointer; + font: inherit; +} + +.user-menu-trigger:hover, +.user-menu.open .user-menu-trigger { + border-color: var(--line); + background: #f9fbff; +} + +.caret { + width: 8px; + height: 8px; + margin-left: 4px; + border-right: 1.5px solid #7b8794; + border-bottom: 1.5px solid #7b8794; + transform: rotate(45deg); +} + +.user-dropdown { + position: absolute; + top: calc(100% + 10px); + right: 0; + min-width: 184px; + padding: 8px; + border: 1px solid var(--line); + border-radius: 14px; + background: #ffffff; + box-shadow: var(--shadow); + opacity: 0; + visibility: hidden; + transform: translateY(-6px); + transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease; +} + +.user-menu.open .user-dropdown { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.user-dropdown-section { + padding: 10px 12px 8px; + border-bottom: 1px solid var(--line); +} + +.user-dropdown-label { + margin: 0 0 6px; + color: var(--muted); + font-size: 12px; +} + +.user-dropdown-name { + display: block; + font-size: 14px; +} + +.user-dropdown-link { + display: flex; + align-items: center; + width: 100%; + min-height: 40px; + padding: 0 12px; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--text); + text-decoration: none; + text-align: left; + cursor: pointer; + font: inherit; +} + +.user-dropdown-link:hover { + background: #f5f8fc; +} + +.user-dropdown-form { + gap: 0; +} + +.danger-link { + color: #c2410c; +} + +.chat-stage { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + min-height: 0; + height: 100%; + background: #ffffff; + overflow: hidden; +} + +.chat-scroll-wrap { + position: relative; + min-height: 0; + height: 100%; +} + +.chat-scroll { + height: 100%; + min-height: 0; + padding: 32px 104px 24px min(6vw, 64px); + overflow-y: auto; + scroll-behavior: smooth; + scrollbar-width: thin; + scrollbar-color: #c4cfdd #f4f7fb; +} + +.chat-scroll::-webkit-scrollbar { + width: 12px; +} + +.chat-scroll::-webkit-scrollbar-track { + background: #f4f7fb; +} + +.chat-scroll::-webkit-scrollbar-thumb { + border: 3px solid #f4f7fb; + border-radius: 999px; + background: #c4cfdd; +} + +.chat-scroll::-webkit-scrollbar-thumb:hover { + background: #a9b8ca; +} + +.hidden { + display: none; +} + +.conversation-header, +.empty-state { + max-width: 920px; + margin: 0 auto 28px; +} + +.conversation-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.conversation-header h1, +.empty-state h1 { + font-size: 32px; +} + +.conversation-meta { + padding-top: 10px; + color: var(--muted); + font-size: 13px; +} + +.message { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + gap: 18px; + max-width: 860px; + margin: 0 auto 18px; +} + +.message.user { + grid-template-columns: minmax(0, 1fr) 44px; +} + +.message.user .message-bubble { + order: -1; + background: #ffffff; +} + +.message-bubble { + padding: 18px 20px; + border: 1px solid var(--line); + border-radius: 18px; + background: #f8fbff; + line-height: 1.7; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; +} + +.message-bubble p, +.message-content p { + margin: 0; + line-height: 1.8; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; +} + +.message-content { + display: grid; + gap: 14px; + line-height: 1.8; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; +} + +.message-content a { + color: var(--accent); + font-weight: 700; + text-decoration: none; +} + +.message-content a:hover { + text-decoration: underline; +} + +.message-bubble.streaming { + position: relative; +} + +.message-bubble.streaming::after { + content: ""; + display: inline-block; + width: 8px; + height: 18px; + margin-left: 6px; + border-radius: 999px; + background: var(--accent); + vertical-align: middle; + animation: pulse-caret 0.9s ease-in-out infinite; +} + +.message, +.conversation-header { + scroll-margin-top: 20px; +} + +.user-mark { + background: #dbe7ff; +} + +.node-rail { + position: absolute; + top: 28px; + right: 28px; + bottom: 28px; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + width: 28px; + pointer-events: none; +} + +.node-rail-line { + position: absolute; + top: 10px; + bottom: 10px; + left: 50%; + width: 2px; + transform: translateX(-50%); + background: linear-gradient(180deg, #eef3fa 0%, #d6dfeb 100%); + border-radius: 999px; +} + +.node-anchor { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 999px; + text-decoration: none; + pointer-events: auto; +} + +.node-dot { + width: 12px; + height: 12px; + border: 2px solid #d8e0eb; + border-radius: 999px; + background: #f5f8fc; + transition: transform 140ms ease, background 140ms ease, border-color 140ms ease; +} + +.node-anchor:hover .node-dot { + transform: scale(1.08); + border-color: #9eb5df; +} + +.node-anchor.active .node-dot { + border-color: var(--accent); + background: var(--accent); +} + +.node-anchor.latest .node-dot { + background: #7f8da3; + border-color: #7f8da3; +} + +.composer-wrap { + padding: 18px 24px 24px; + border-top: 1px solid var(--line); + background: #ffffff; +} + +.composer { + max-width: 860px; + margin: 0 auto; + padding: 14px; + border: 1px solid var(--line); + border-radius: 24px; + background: #ffffff; + box-shadow: 0 8px 24px rgba(31, 42, 55, 0.06); +} + +.composer textarea { + width: 100%; + min-height: 36px; + max-height: 180px; + resize: vertical; + border: 0; + background: transparent; + color: var(--text); + font: inherit; + line-height: 1.7; + outline: none; +} + +.composer-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 12px; +} + +.composer-tools { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.tool-chip { + height: 34px; + padding: 0 14px; + background: var(--panel-muted); + color: var(--accent); +} + +.passive-chip { + display: inline-flex; + align-items: center; + border-radius: 999px; + font-size: 13px; + font-weight: 600; +} + +.send-button { + height: 40px; + min-width: 88px; + padding: 0 18px; + background: var(--accent); + color: #ffffff; + font-weight: 700; +} + +.send-button:disabled { + background: #a8bee8; + cursor: wait; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.workspace[data-sidebar-state="collapsed"] { + grid-template-columns: 88px minmax(0, 1fr) 340px; +} + +.workspace[data-sidebar-state="collapsed"] .brand-text, +.workspace[data-sidebar-state="collapsed"] .brand-subtitle, +.workspace[data-sidebar-state="collapsed"] .new-chat, +.workspace[data-sidebar-state="collapsed"] .search-form, +.workspace[data-sidebar-state="collapsed"] .sidebar-label, +.workspace[data-sidebar-state="collapsed"] .history-title, +.workspace[data-sidebar-state="collapsed"] .history-meta, +.workspace[data-sidebar-state="collapsed"] .history-delete { + display: none; +} + +.workspace[data-sidebar-state="collapsed"] .history-item { + grid-template-columns: minmax(0, 1fr); + place-items: center; + padding: 12px; +} + +.workspace[data-sidebar-state="collapsed"] .sidebar { + padding-left: 12px; + padding-right: 12px; +} + +.workspace[data-sidebar-state="collapsed"] .sidebar-header { + justify-content: center; +} + +.workspace[data-sidebar-state="collapsed"] .brand { + display: none; +} + +.summary-panel { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 14px; + min-width: 0; + max-height: 100%; + padding: 16px; + overflow: auto; + border-left: 1px solid var(--line); + background: #ffffff; +} + +.summary-section { + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); +} + +.summary-heading, +.summary-subheading, +.workflow-card header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.summary-heading h2, +.summary-subheading h3 { + margin: 0; + font-size: 16px; +} + +.summary-heading span { + color: var(--muted); + font-size: 12px; +} + +.upload-dropzone { + display: grid; + place-items: center; + gap: 6px; + min-height: 112px; + padding: 18px; + border: 1px dashed var(--accent); + border-radius: 8px; + background: #f5f9ff; + color: var(--text); + cursor: pointer; + text-align: center; +} + +.upload-dropzone.dragging { + border-color: var(--accent-dark); + background: #eaf2ff; +} + +.upload-dropzone span, +.upload-status, +.attachment-item span, +.workflow-card em, +.workflow-card small, +.workflow-error, +.workflow-notification { + color: var(--muted); + font-size: 12px; +} + +.attachment-manager-link { + display: inline-grid; + place-items: center; + width: 28px; + height: 28px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--accent); + text-decoration: none; + font-weight: 700; +} + +.attachment-manager-link:hover { + border-color: var(--accent); + background: #eaf2ff; +} + +.upload-status { + margin: 0; + line-height: 1.5; +} + +.attachment-list, +.workflow-card-list { + display: grid; + gap: 10px; +} + +.attachment-item, +.workflow-card { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; +} + +.attachment-item { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +.attachment-item strong, +.workflow-card strong { + display: block; + overflow-wrap: anywhere; + font-size: 13px; +} + +.attachment-item em, +.workflow-status { + padding: 3px 8px; + border-radius: 999px; + background: #eaf2ff; + color: var(--accent); + font-size: 11px; + font-style: normal; + font-weight: 700; +} + +.workflow-card ol { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.workflow-batch-carousel { + gap: 10px; +} + +.workflow-batch-carousel .workflow-card { + display: none; +} + +.workflow-batch-carousel .workflow-card.active { + display: grid; +} + +.workflow-batch-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 30px; +} + +.workflow-batch-btn { + display: inline-grid; + place-items: center; + width: 28px; + height: 28px; + border: 1px solid var(--line); + border-radius: 999px; + background: #ffffff; + color: var(--text); + cursor: pointer; + font-size: 18px; + line-height: 1; +} + +.workflow-batch-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.workflow-batch-dots { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 0; +} + +.workflow-batch-dot { + width: 7px; + height: 7px; + padding: 0; + border: 0; + border-radius: 999px; + background: #cbd5e1; + cursor: pointer; +} + +.workflow-batch-dot.active { + width: 18px; + background: var(--accent); +} + +.node-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 0; + border-top: 1px solid var(--line); + font-size: 13px; +} + +.node-status div { + display: grid; + min-width: 0; + gap: 2px; +} + +.node-status span, +.node-status small, +.workflow-error, +.workflow-notification { + overflow-wrap: anywhere; + word-break: break-word; +} + +.workflow-error, +.workflow-notification { + margin: 0; + padding: 8px 10px; + border-radius: 6px; + line-height: 1.5; +} + +.workflow-error { + background: #fff1f0; + color: #b42318; +} + +.workflow-notification { + background: #f5fbf7; + color: #166534; +} + +.workflow-notification[data-notification-status="failed"] { + background: #fff1f0; + color: #b42318; +} + +.status-running, +.status-retrying { + color: var(--accent); +} + +.status-success { + color: #047857; +} + +.status-failed { + color: var(--danger-text); +} + +.panel-empty { + padding: 14px; + border: 1px dashed var(--line); + border-radius: 8px; + color: var(--muted); + text-align: center; +} + +.message-bubble table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + line-height: 1.6; + table-layout: fixed; +} + +.message-bubble th, +.message-bubble td { + padding: 8px; + border: 1px solid var(--line); + text-align: left; + vertical-align: top; + overflow-wrap: anywhere; + word-break: break-word; +} + +.message-bubble pre { + max-width: 100%; + overflow-x: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.message-bubble code { + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +@media (max-width: 980px) { + .app-body { + overflow: auto; + } + + .app-shell { + grid-template-rows: 60px auto; + height: auto; + min-height: 100vh; + } + + .workspace { + grid-template-columns: minmax(0, 1fr); + height: auto; + min-height: 0; + overflow: visible; + } + + .sidebar { + position: fixed; + inset: 60px auto 0 0; + width: 280px; + z-index: 20; + box-shadow: var(--shadow); + transform: translateX(-100%); + } + + .workspace[data-sidebar-state="open"] .sidebar { + transform: translateX(0); + } + + .chat-shell { + padding: 10px; + } + + .mobile-toggle { + display: inline-flex; + } + + .topbar, + .chat-scroll, + .composer-wrap { + padding-left: 16px; + padding-right: 16px; + } + + .topbar { + align-items: center; + flex-direction: row; + min-height: 60px; + padding-top: 0; + padding-bottom: 0; + } + + .topbar-left { + flex: 1 1 auto; + overflow: hidden; + } + + .topbar-right { + flex: 0 0 auto; + width: auto; + justify-content: flex-end; + } + + .conversation-header { + flex-direction: column; + } + + .chat-stage { + min-height: calc(100vh - 60px); + height: auto; + } + + .summary-panel { + max-height: none; + border-left: 0; + border-top: 1px solid var(--line); + } + + .chat-scroll { + padding-right: 72px; + } + + .node-rail { + right: 14px; + } +} + +.attachment-manager-page { + display: grid; + align-content: start; + gap: 12px; + min-height: 0; + height: calc(100vh - 60px); + overflow-y: auto; + padding: 16px 24px 20px; + background: var(--bg); +} + +.attachment-manager-hero, +.attachment-manager-panel, +.attachment-manager-content { + width: min(1440px, 100%); + margin: 0 auto; +} + +.attachment-manager-hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0; +} + +.attachment-manager-hero h1 { + margin: 2px 0; + font-size: 22px; +} + +.attachment-manager-hero p { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.attachment-manager-toolbar { + min-height: 66px; +} + +.attachment-manager-selectbar { + display: grid; + grid-template-columns: auto minmax(420px, 680px) auto; + align-items: center; + gap: 10px; + min-width: min(900px, 60vw); +} + +.attachment-manager-selectbar label { + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.return-chat-link { + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--accent); + text-decoration: none; + font-weight: 700; +} + +.attachment-manager-panel { + display: grid; + gap: 10px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; +} + +.attachment-manager-panel label { + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.attachment-manager-panel select, +.attachment-search { + min-height: 34px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + color: var(--text); + font: inherit; +} + +.attachment-manager-panel select, +.attachment-manager-select-control { + width: 100%; + height: 38px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + color: var(--text); + font: inherit; +} + +.attachment-manager-select-control:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(58, 114, 216, 0.14); + outline: none; +} + +.attachment-manager-content { + display: grid; + gap: 12px; +} + +.attachment-manager-split { + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + align-items: start; +} + +.attachment-search { + width: 220px; + padding: 0 10px; +} + +.manager-upload-dropzone { + min-height: 132px; + padding: 14px; +} + +.upload-manager-panel .summary-subheading span { + color: var(--muted); + font-size: 12px; +} + +.attachment-table-wrap { + overflow-x: auto; +} + +.attachment-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.attachment-table th, +.attachment-table td { + padding: 10px 8px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: middle; +} + +.attachment-table th { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.attachment-name { + max-width: 360px; + overflow-wrap: anywhere; + font-weight: 700; +} + +.attachment-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.attachment-actions a, +.attachment-actions button { + min-height: 28px; + padding: 4px 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: #ffffff; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 700; + text-decoration: none; +} + +.attachment-actions a:hover, +.attachment-actions button:hover { + border-color: var(--accent); + background: #eaf2ff; +} + +.dashboard-page { + display: grid; + align-content: start; + gap: 12px; + height: calc(100vh - 60px); + overflow-y: auto; + padding: 16px 24px 20px; + background: var(--bg); +} + +.dashboard-hero, +.metric-grid, +.dashboard-split, +.dashboard-panel { + width: min(1440px, 100%); + margin: 0 auto; +} + +.dashboard-hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0; +} + +.dashboard-hero h1 { + margin: 2px 0; + font-size: 22px; +} + +.dashboard-hero p { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.dashboard-primary-action { + background: #ffffff; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.metric-card { + display: grid; + gap: 8px; + min-height: 104px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; +} + +.metric-card span, +.metric-card em { + color: var(--muted); + font-size: 12px; + font-style: normal; + font-weight: 700; +} + +.metric-card strong { + color: var(--text); + font-size: 30px; + line-height: 1; +} + +.dashboard-split { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.dashboard-stat-list { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin: 0; +} + +.dashboard-stat-list div { + display: grid; + gap: 6px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #f8fafc; +} + +.dashboard-stat-list dt { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.dashboard-stat-list dd { + margin: 0; + color: var(--text); + font-size: 22px; + font-weight: 800; +} + +.recent-activity-table td { + height: 44px; +} + +.table-empty, +.attachment-manager-empty { + color: var(--muted); + text-align: center; +} + +.attachment-manager-empty { + min-height: 150px; + place-content: center; +} + +.attachment-manager-empty h2 { + margin: 0; + font-size: 18px; +} + +.attachment-manager-empty p { + margin: 0; +} + +.knowledge-page { + display: grid; + align-content: start; + gap: 12px; + height: calc(100vh - 60px); + min-height: 0; + overflow-y: auto; + padding: 16px 24px 20px; + background: var(--bg); +} + +.knowledge-hero, +.knowledge-status-panel, +.knowledge-grid, +.knowledge-content, +.knowledge-workbench, +.knowledge-summary-row, +.knowledge-main-grid, +.knowledge-secondary-grid, +.knowledge-panel { + width: min(1440px, 100%); + margin: 0 auto; +} + +.knowledge-hero-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.knowledge-hero h1 { + margin: 2px 0; + font-size: 22px; +} + +.knowledge-hero p { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.knowledge-status { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 12px; + border-radius: 999px; + font-size: 13px; + font-weight: 700; + white-space: nowrap; +} + +.knowledge-status.status-ready { + background: #ecfdf3; + color: #047857; +} + +.knowledge-status.status-thin { + background: #fff7ed; + color: #c2410c; +} + +.knowledge-status.status-missing { + background: #fff1f2; + color: var(--danger-text); +} + +.knowledge-summary-row { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; +} + +.knowledge-summary-item { + display: grid; + gap: 4px; + min-height: 68px; + padding: 12px 14px; + border-right: 1px solid var(--line); + background: #ffffff; +} + +.knowledge-summary-item:last-child { + border-right: 0; +} + +.knowledge-summary-item span { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.knowledge-summary-item strong { + font-size: 22px; + line-height: 1.1; +} + +.knowledge-summary-item small { + color: var(--muted); + font-size: 12px; +} + +.knowledge-status-message { + grid-column: 1 / -1; + margin: 0; + padding: 10px 12px; + border-radius: 8px; + background: #f8fbff; + color: #344054; + font-size: 13px; + line-height: 1.6; +} + +.knowledge-grid, +.knowledge-main-grid, +.knowledge-secondary-grid { + display: grid; + gap: 12px; +} + +.knowledge-content { + display: grid; + gap: 12px; +} + +.knowledge-workbench { + grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); + align-items: start; +} + +.knowledge-main-grid { + grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); + align-items: start; +} + +.knowledge-secondary-grid { + grid-template-columns: minmax(340px, 0.8fr) minmax(0, 1.2fr); + align-items: start; +} + +.knowledge-left-stack, +.knowledge-right-stack, +.knowledge-left-rail, +.knowledge-right-display { + display: grid; + gap: 12px; + min-width: 0; +} + +.knowledge-panel { + display: grid; + gap: 10px; +} + +.knowledge-panel h2 { + margin: 0; + font-size: 16px; +} + +.knowledge-system-panel { + display: grid; + gap: 12px; +} + +.knowledge-system-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.knowledge-system-header h2 { + margin: 0 0 4px; + font-size: 16px; +} + +.knowledge-system-header p { + margin: 0; + color: #344054; + font-size: 13px; + line-height: 1.6; +} + +.knowledge-system-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.knowledge-document-form { + display: grid; + gap: 10px; +} + +.knowledge-document-form label { + display: grid; + gap: 6px; +} + +.knowledge-document-form label span { + color: #344054; + font-size: 13px; + font-weight: 700; +} + +.knowledge-document-form input[type="text"], +.knowledge-document-form input[type="file"], +.knowledge-document-form textarea { + width: 100%; + min-height: 36px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + color: var(--text); + font: inherit; +} + +.knowledge-upload-dropzone { + min-height: 156px; + cursor: pointer; +} + +.knowledge-upload-dropzone strong { + color: var(--text); + font-size: 16px; +} + +.knowledge-document-form textarea { + resize: vertical; + line-height: 1.6; +} + +.knowledge-document-form input:focus, +.knowledge-document-form textarea:focus, +.knowledge-search-form input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(58, 114, 216, 0.14); + outline: none; +} + +.knowledge-checkbox { + display: flex !important; + grid-template-columns: auto 1fr; + align-items: center; + gap: 8px !important; +} + +.knowledge-checkbox input { + width: 16px; + height: 16px; +} + +.knowledge-form-actions, +.knowledge-toolbar-actions, +.knowledge-inline-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.knowledge-inline-actions { + justify-content: space-between; +} + +.knowledge-inline-actions .knowledge-checkbox { + min-height: 34px; +} + +.knowledge-form-actions button, +.knowledge-toolbar-actions button, +.knowledge-inline-actions button { + min-height: 34px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 700; +} + +.knowledge-form-actions button[type="submit"], +.knowledge-inline-actions button { + border: 0; + background: var(--accent); + color: #ffffff; +} + +.knowledge-toolbar-actions button:disabled { + color: var(--muted); + cursor: not-allowed; + opacity: 0.68; +} + +.knowledge-definition-list { + display: grid; + gap: 8px; + margin: 0; +} + +.knowledge-definition-list div { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 10px; + padding: 8px 0; + border-top: 1px solid var(--line); +} + +.knowledge-definition-list dt { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.knowledge-definition-list dd { + margin: 0; + overflow-wrap: anywhere; + color: var(--text); + font-size: 13px; +} + +.knowledge-command-box { + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: #f8fbff; +} + +.knowledge-command-box strong { + font-size: 13px; +} + +.knowledge-command-box code { + display: block; + overflow-wrap: anywhere; + color: #1f2a37; + font-size: 12px; + line-height: 1.5; +} + +.knowledge-severity-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.knowledge-severity-list span { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + background: #eaf2ff; + color: var(--accent); + font-size: 12px; + font-weight: 700; +} + +.knowledge-search-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; +} + +.knowledge-search-form input { + min-height: 36px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--text); + font: inherit; +} + +.knowledge-search-form button { + min-height: 36px; + padding: 0 14px; + border: 0; + border-radius: 8px; + background: var(--accent); + color: #ffffff; + cursor: pointer; + font: inherit; + font-weight: 700; +} + +.knowledge-search-results { + display: grid; + gap: 10px; +} + +.knowledge-panel-note { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} + +.knowledge-compact-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0; + overflow: hidden; + margin: 0; + border: 1px solid var(--line); + border-radius: 8px; +} + +.knowledge-compact-stats div { + display: grid; + gap: 4px; + padding: 10px; + border-right: 1px solid var(--line); +} + +.knowledge-compact-stats div:last-child { + border-right: 0; +} + +.knowledge-compact-stats dt { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.knowledge-compact-stats dd { + margin: 0; + color: var(--text); + font-size: 18px; + font-weight: 800; + line-height: 1; +} + +.knowledge-result { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); +} + +.knowledge-result header { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.knowledge-result header strong { + font-size: 13px; +} + +.knowledge-result header span, +.knowledge-result em { + overflow-wrap: anywhere; + color: var(--muted); + font-size: 12px; +} + +.knowledge-result p, +.knowledge-search-error { + margin: 0; + color: #344054; + font-size: 13px; + line-height: 1.7; + overflow-wrap: anywhere; +} + +.knowledge-search-error { + padding: 10px 12px; + border-radius: 8px; + background: #fff1f2; + color: var(--danger-text); +} + +.knowledge-source-table th:first-child, +.knowledge-source-table td:first-child, +.knowledge-document-table th:first-child, +.knowledge-document-table td:first-child, +.knowledge-source-table th:nth-child(3), +.knowledge-source-table td:nth-child(3), +.knowledge-source-table th:nth-child(4), +.knowledge-source-table td:nth-child(4), +.knowledge-source-table th:nth-child(5), +.knowledge-source-table td:nth-child(5), +.knowledge-document-table th:nth-child(4), +.knowledge-document-table td:nth-child(4), +.knowledge-document-table th:nth-child(5), +.knowledge-document-table td:nth-child(5), +.knowledge-document-table th:nth-child(6), +.knowledge-document-table td:nth-child(6) { + white-space: nowrap; +} + +.knowledge-page .summary-subheading h3 { + color: var(--text); + font-size: 16px; + line-height: 1.3; +} + +.knowledge-page input[type="text"], +.knowledge-page input[type="search"], +.knowledge-page textarea { + width: 100%; + min-height: 38px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + color: var(--text); + font: inherit; + font-size: 14px; + outline: none; +} + +.knowledge-page textarea { + min-height: 44px; + padding-top: 9px; + padding-bottom: 9px; + resize: vertical; + line-height: 1.5; +} + +.knowledge-page input[type="text"]:focus, +.knowledge-page input[type="search"]:focus, +.knowledge-page textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(58, 114, 216, 0.14); +} + +.knowledge-page button { + min-height: 34px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 700; +} + +.knowledge-page button[type="submit"], +.knowledge-inline-actions button { + border-color: var(--accent); + background: var(--accent); + color: #ffffff; +} + +.knowledge-page button:hover:not(:disabled) { + border-color: var(--accent); + background: #eaf2ff; +} + +.knowledge-page button[type="submit"]:hover:not(:disabled), +.knowledge-inline-actions button:hover:not(:disabled) { + background: var(--accent-dark); + color: #ffffff; +} + +.knowledge-page button:disabled { + border-color: var(--line); + background: #f3f6fb; + color: var(--muted); + cursor: not-allowed; + opacity: 1; +} + +.knowledge-page .panel-empty { + margin: 0; + padding: 18px 16px; + border: 1px dashed var(--line); + border-radius: 8px; + background: #fbfdff; + color: var(--muted); + font-size: 13px; + line-height: 1.6; + text-align: center; +} + +.knowledge-upload-panel .summary-subheading span { + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.knowledge-upload-dropzone { + background: #f7faff; + text-align: center; +} + +.knowledge-upload-dropzone:hover { + border-color: var(--accent); + background: #eef5ff; +} + +.knowledge-parse-panel .knowledge-status { + min-height: 28px; + border-radius: 8px; + font-size: 12px; +} + +.knowledge-document-list-panel, +.knowledge-source-panel { + min-height: 152px; +} + +.knowledge-right-display .attachment-table th, +.knowledge-right-display .attachment-table td { + padding-top: 11px; + padding-bottom: 11px; +} + +.knowledge-document-list-panel .summary-subheading h3, +.knowledge-source-panel .summary-subheading h3 { + max-width: none; + white-space: nowrap; +} + +.knowledge-document-table th:nth-child(5), +.knowledge-document-table td:nth-child(5) { + white-space: nowrap; +} + +@media (max-width: 640px) { + .tabbar { + overflow-x: auto; + max-width: 100%; + } + + .user-card { + min-width: 0; + } + + .user-menu-trigger { + min-width: 0; + } + + .user-copy strong, + .user-copy span { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .message, + .message.user { + grid-template-columns: 1fr; + } + + .message-avatar { + display: none; + } + + .composer-actions { + align-items: stretch; + flex-direction: column; + } + + .composer-tools { + width: 100%; + } + + .send-button { + width: 100%; + } + + .chat-shell { + padding: 0; + } + + .chat-stage { + min-height: calc(100vh - 60px); + height: auto; + } + + .chat-scroll { + padding-right: 44px; + } + + .node-rail { + right: 8px; + gap: 10px; + width: 20px; + } + + .node-dot { + width: 10px; + height: 10px; + } + + .attachment-manager-page { + height: auto; + min-height: calc(100vh - 60px); + padding: 12px; + } + + .attachment-manager-hero { + align-items: stretch; + flex-direction: column; + } + + .attachment-manager-selectbar { + grid-template-columns: 1fr; + min-width: 0; + } + + .attachment-manager-split { + grid-template-columns: 1fr; + } + + .knowledge-workbench { + grid-template-columns: 1fr; + } + + .attachment-search { + width: 100%; + } + + .knowledge-page { + height: auto; + min-height: calc(100vh - 60px); + padding: 12px; + } + + .knowledge-hero { + align-items: stretch; + flex-direction: column; + } + + .knowledge-status-panel, + .knowledge-summary-row, + .knowledge-grid, + .knowledge-main-grid, + .knowledge-secondary-grid, + .knowledge-system-grid, + .knowledge-search-form { + grid-template-columns: 1fr; + } + + .knowledge-summary-item { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .knowledge-summary-item:last-child { + border-bottom: 0; + } + + .knowledge-hero-actions { + align-items: stretch; + flex-direction: column; + } + + .knowledge-toolbar-actions, + .knowledge-form-actions, + .knowledge-inline-actions { + align-items: stretch; + flex-direction: column; + } + + .knowledge-toolbar-actions .attachment-search, + .knowledge-toolbar-actions button, + .knowledge-form-actions button, + .knowledge-inline-actions button { + width: 100%; + } + + .knowledge-compact-stats { + grid-template-columns: 1fr; + } + + .knowledge-compact-stats div { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .knowledge-compact-stats div:last-child { + border-bottom: 0; + } + + .knowledge-definition-list div { + grid-template-columns: 1fr; + gap: 4px; + } + + .dashboard-page { + height: auto; + min-height: calc(100vh - 60px); + padding: 12px; + } + + .dashboard-hero { + align-items: stretch; + flex-direction: column; + } + + .metric-grid, + .dashboard-split, + .dashboard-stat-list { + grid-template-columns: 1fr; + } +} + +@keyframes pulse-caret { + 0%, + 100% { + opacity: 0.25; + } + 50% { + opacity: 1; + } +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..f99d460 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,1285 @@ +(function () { + var workspace = document.querySelector(".workspace"); + var sidebarToggle = document.getElementById("sidebarToggle"); + var mobileSidebarToggle = document.getElementById("mobileSidebarToggle"); + var userMenu = document.getElementById("userMenu"); + var userMenuTrigger = document.getElementById("userMenuTrigger"); + var chatScroll = document.getElementById("chatScroll"); + var nodeRail = document.getElementById("nodeRail"); + var composer = document.getElementById("chatComposer"); + var promptInput = document.getElementById("prompt"); + var sendButton = document.getElementById("sendButton"); + var conversationIdInput = document.getElementById("conversationIdInput"); + var chatStage = document.querySelector(".chat-stage"); + var summaryPanel = document.getElementById("summaryPanel"); + var uploadDropzone = document.getElementById("uploadDropzone"); + var attachmentInput = document.getElementById("attachmentInput"); + var attachmentList = document.getElementById("attachmentList"); + var uploadStatus = document.getElementById("uploadStatus"); + var workflowCardList = document.getElementById("workflowCardList"); + var nodeAnchors = []; + var workflowPollingTimers = {}; + var WORKFLOW_POLL_INTERVAL_MS = 1500; + var latestMessageId = 0; + + if (!workspace) { + return; + } + + function getCsrfToken() { + if (!composer) { + return ""; + } + return new FormData(composer).get("csrfmiddlewaretoken") || ""; + } + + function isMobile() { + return window.matchMedia("(max-width: 980px)").matches; + } + + function toggleSidebar() { + var state = workspace.getAttribute("data-sidebar-state"); + if (isMobile()) { + workspace.setAttribute("data-sidebar-state", state === "open" ? "closed" : "open"); + return; + } + workspace.setAttribute("data-sidebar-state", state === "collapsed" ? "open" : "collapsed"); + } + + function syncSidebarState() { + if (isMobile()) { + if (workspace.getAttribute("data-sidebar-state") !== "closed") { + workspace.setAttribute("data-sidebar-state", "closed"); + } + } else if (workspace.getAttribute("data-sidebar-state") === "closed") { + workspace.setAttribute("data-sidebar-state", "open"); + } + } + + function refreshNodeAnchors() { + nodeAnchors = Array.prototype.slice.call(document.querySelectorAll(".node-anchor")); + } + + function syncLatestMessageIdFromDom() { + document.querySelectorAll(".message[data-message-id]").forEach(function (message) { + var id = parseInt(message.getAttribute("data-message-id"), 10); + if (!Number.isNaN(id)) { + latestMessageId = Math.max(latestMessageId, id); + } + }); + } + + if (sidebarToggle) { + sidebarToggle.addEventListener("click", toggleSidebar); + } + + if (mobileSidebarToggle) { + mobileSidebarToggle.addEventListener("click", toggleSidebar); + } + + if (userMenu && userMenuTrigger) { + userMenuTrigger.addEventListener("click", function () { + var isOpen = userMenu.classList.toggle("open"); + userMenuTrigger.setAttribute("aria-expanded", isOpen ? "true" : "false"); + }); + + document.addEventListener("click", function (event) { + if (!userMenu.contains(event.target)) { + userMenu.classList.remove("open"); + userMenuTrigger.setAttribute("aria-expanded", "false"); + } + }); + } + + function setActiveNode() { + if (!chatScroll || !nodeAnchors.length) { + return; + } + + var activeTarget = nodeAnchors[0].getAttribute("data-target"); + var scrollTop = chatScroll.scrollTop; + var threshold = 80; + + nodeAnchors.forEach(function (anchor) { + var targetId = anchor.getAttribute("data-target"); + var target = document.getElementById(targetId); + if (!target) { + return; + } + + if (target.offsetTop - threshold <= scrollTop) { + activeTarget = targetId; + } + }); + + nodeAnchors.forEach(function (anchor) { + anchor.classList.toggle("active", anchor.getAttribute("data-target") === activeTarget); + }); + } + + function bindNodeAnchorClicks() { + if (!chatScroll) { + return; + } + nodeAnchors.forEach(function (anchor) { + if (anchor.dataset.bound === "true") { + return; + } + anchor.dataset.bound = "true"; + anchor.addEventListener("click", function (event) { + event.preventDefault(); + var targetId = anchor.getAttribute("data-target"); + var target = document.getElementById(targetId); + if (!target) { + return; + } + chatScroll.scrollTo({ + top: Math.max(target.offsetTop - 20, 0), + behavior: "smooth", + }); + }); + }); + } + + function ensureNodeRailVisible() { + if (nodeRail) { + nodeRail.classList.remove("hidden"); + } + } + + function syncNodeRailVisibility() { + if (!nodeRail) { + return; + } + refreshNodeAnchors(); + if (nodeAnchors.length) { + nodeRail.classList.remove("hidden"); + } else { + nodeRail.classList.add("hidden"); + } + } + + function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); + } + + function nl2br(text) { + return escapeHtml(text).replace(/\n/g, "
"); + } + + function renderInlineMarkdown(text) { + return escapeHtml(text || "").replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_match, label, href) { + var safeHref = escapeHtml(href); + var safeLabel = escapeHtml(label); + if (!/^\/[^/\\]/.test(href) && !/^https?:\/\//.test(href)) { + return safeLabel; + } + return '' + safeLabel + ""; + }); + } + + function renderMarkdownTable(lines, startIndex) { + var header = lines[startIndex].trim(); + var separator = lines[startIndex + 1] ? lines[startIndex + 1].trim() : ""; + if (header.charAt(0) !== "|" || separator.indexOf("---") === -1) { + return null; + } + + function cells(line) { + return line + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map(function (cell) { + return cell.trim(); + }); + } + + var html = ""; + cells(header).forEach(function (cell) { + html += ""; + }); + html += ""; + + var index = startIndex + 2; + while (index < lines.length && lines[index].trim().charAt(0) === "|") { + html += ""; + cells(lines[index]).forEach(function (cell) { + html += ""; + }); + html += ""; + index += 1; + } + html += "
" + renderInlineMarkdown(cell) + "
" + renderInlineMarkdown(cell || "-") + "
"; + return { html: html, nextIndex: index }; + } + + function renderBasicMarkdown(text) { + var lines = (text || "").split(/\r?\n/); + var html = ""; + var paragraph = []; + var index = 0; + + function flushParagraph() { + if (!paragraph.length) { + return; + } + html += "

" + renderInlineMarkdown(paragraph.join("\n")).replace(/\n/g, "
") + "

"; + paragraph = []; + } + + while (index < lines.length) { + var line = lines[index]; + var table = renderMarkdownTable(lines, index); + if (table) { + flushParagraph(); + html += table.html; + index = table.nextIndex; + continue; + } + if (!line.trim()) { + flushParagraph(); + } else { + paragraph.push(line); + } + index += 1; + } + flushParagraph(); + return html; + } + + function renderAssistantContent(text) { + try { + if (window.marked && window.DOMPurify) { + return window.DOMPurify.sanitize(window.marked.parse(text || "")); + } + return renderBasicMarkdown(text || ""); + } catch (error) { + console.error("Markdown render failed", error); + return nl2br(text || ""); + } + } + + function renderExistingAssistantMessages() { + document.querySelectorAll(".message.assistant .message-bubble").forEach(function (bubble) { + var target = bubble.querySelector(".markdown-content"); + var raw = bubble.querySelector(".message-raw"); + if (!target || !raw || target.dataset.rendered === "true") { + return; + } + target.innerHTML = renderAssistantContent(raw.content ? raw.content.textContent : raw.textContent); + target.dataset.rendered = "true"; + }); + } + + function scrollChatToBottom() { + if (chatScroll) { + chatScroll.scrollTop = chatScroll.scrollHeight; + } + } + + function createMessage(role, content, messageId, label) { + var article = document.createElement("article"); + article.className = "message " + role; + article.id = messageId; + if (typeof messageId === "number") { + article.setAttribute("data-message-id", messageId); + } + if (label) { + article.setAttribute("data-node-label", label); + } + + var avatar = document.createElement("div"); + avatar.className = "message-avatar" + (role === "user" ? " user-mark" : ""); + avatar.textContent = role === "assistant" ? "AI" : userMenuTrigger.querySelector(".avatar").textContent.trim(); + + var bubble = document.createElement("div"); + bubble.className = "message-bubble"; + + var text = document.createElement(role === "assistant" ? "div" : "p"); + if (role === "assistant") { + text.className = "message-content markdown-content"; + } + text.innerHTML = role === "assistant" ? renderAssistantContent(content) : nl2br(content); + bubble.appendChild(text); + + article.appendChild(avatar); + article.appendChild(bubble); + chatScroll.appendChild(article); + return { article: article, bubble: bubble, text: text }; + } + + function appendConversationMessage(message) { + if (!message || document.querySelector('.message[data-message-id="' + message.id + '"]')) { + return false; + } + var label = message.role === "assistant" ? "AI " : "用户 "; + label += document.querySelectorAll(".message").length + 1; + var created = createMessage(message.role, message.content || "", "message-" + message.id, label); + created.article.setAttribute("data-message-id", message.id); + latestMessageId = Math.max(latestMessageId, message.id); + if (message.role === "user") { + appendNode(created.article.id, label, true); + } + return true; + } + + async function refreshConversationMessages() { + var conversationId = currentConversationId(); + if (!conversationId || !summaryPanel) { + return; + } + var url = templateUrl("data-message-url-template", "__conversation_id__", conversationId); + if (!url) { + return; + } + try { + var response = await fetch(url + "?after=" + latestMessageId, { cache: "no-store" }); + if (!response.ok) { + return; + } + var payload = await response.json(); + var appendedCount = 0; + (payload.messages || []).forEach(function (message) { + if (appendConversationMessage(message)) { + appendedCount += 1; + } + }); + if (payload.latest_message_id) { + latestMessageId = Math.max(latestMessageId, payload.latest_message_id); + } + syncNodeRailVisibility(); + bindNodeAnchorClicks(); + setActiveNode(); + if (appendedCount > 0) { + scrollChatToBottom(); + } + } catch (error) { + console.error("Conversation message refresh failed", error); + } + } + + function appendNode(targetId, title, isLatest) { + if (!nodeRail) { + return; + } + ensureNodeRailVisible(); + var anchor = document.createElement("a"); + anchor.className = "node-anchor" + (isLatest ? " latest" : ""); + anchor.href = "#" + targetId; + anchor.setAttribute("data-target", targetId); + anchor.title = title; + + var dot = document.createElement("span"); + dot.className = "node-dot"; + anchor.appendChild(dot); + nodeRail.appendChild(anchor); + syncNodeRailVisibility(); + bindNodeAnchorClicks(); + setActiveNode(); + } + + function updateSidebarConversation(conversationId, title) { + if (!conversationId || !title) { + return; + } + var encodedTitle = title; + var existing = document.querySelector('.history-item[data-conversation-id="' + conversationId + '"]'); + var list = document.querySelector(".history-list"); + var currentTime = new Date(); + var month = String(currentTime.getMonth() + 1).padStart(2, "0"); + var day = String(currentTime.getDate()).padStart(2, "0"); + var hours = String(currentTime.getHours()).padStart(2, "0"); + var minutes = String(currentTime.getMinutes()).padStart(2, "0"); + var meta = month + "月" + day + "日 " + hours + ":" + minutes; + + document.querySelectorAll(".history-item.active").forEach(function (item) { + item.classList.remove("active"); + }); + + if (existing) { + existing.classList.add("active"); + existing.querySelector(".history-title").textContent = encodedTitle; + existing.querySelector(".history-meta").textContent = meta; + if (list.firstElementChild !== existing) { + list.prepend(existing); + } + return; + } + + if (!list) { + return; + } + + var empty = list.querySelector(".history-empty"); + if (empty) { + empty.remove(); + } + + var item = document.createElement("div"); + item.className = "history-item active"; + item.setAttribute("data-conversation-id", conversationId); + item.setAttribute("data-delete-url", "/api/review-agent/conversations/" + conversationId + "/"); + item.innerHTML = + '' + + escapeHtml(encodedTitle) + + '' + + meta + + ''; + list.prepend(item); + } + + async function deleteConversation(item) { + if (!item) { + return; + } + var url = item.getAttribute("data-delete-url"); + var conversationId = item.getAttribute("data-conversation-id"); + if (!url || !conversationId) { + return; + } + var titleNode = item.querySelector(".history-title"); + var title = titleNode ? titleNode.textContent.trim() : "这个对话"; + if (!window.confirm('确定删除对话“' + title + '”?')) { + return; + } + var response = await fetch(url, { + method: "DELETE", + headers: { + "X-CSRFToken": getCsrfToken(), + }, + }); + if (!response.ok) { + throw new Error("删除对话失败"); + } + var isCurrent = currentConversationId() === conversationId; + item.remove(); + var list = document.querySelector(".history-list"); + if (list && !list.querySelector(".history-item")) { + var empty = document.createElement("div"); + empty.className = "history-empty"; + empty.innerHTML = "

暂无会话记录

点击上方“新对话”开始审核。"; + list.appendChild(empty); + } + if (isCurrent) { + window.location.href = "/"; + } + } + + function setConversationTitle(title) { + if (!title) { + return; + } + var header = document.querySelector(".conversation-header h1"); + var empty = document.querySelector(".empty-state"); + if (empty) { + empty.remove(); + var headerWrap = document.createElement("div"); + headerWrap.className = "conversation-header"; + headerWrap.id = "conversation-top"; + headerWrap.setAttribute("data-node-label", "会话开始"); + headerWrap.innerHTML = + '

审核智能体

' + + escapeHtml(title) + + '

正在生成回复'; + chatScroll.prepend(headerWrap); + return; + } + if (header) { + header.textContent = title; + } + } + + function currentConversationId() { + return conversationIdInput ? conversationIdInput.value : ""; + } + + function templateUrl(attributeName, token, value) { + if (!summaryPanel) { + return ""; + } + return summaryPanel.getAttribute(attributeName).replace(token, value); + } + + function statusUrlForWorkflow(workflow_type, batchId) { + var attributeName = "data-status-url-template"; + if (workflow_type === "regulatory_review") { + attributeName = "data-regulatory-status-url-template"; + } else if (workflow_type === "application_form_fill") { + attributeName = "data-application-form-fill-status-url-template"; + } else if (workflow_type === "regulatory_info_package") { + attributeName = "data-regulatory-info-package-status-url-template"; + } + return templateUrl(attributeName, "__batch_id__", batchId); + } + + function renderAttachments(attachments) { + if (!attachmentList) { + return; + } + attachmentList.innerHTML = ""; + if (!attachments.length) { + attachmentList.innerHTML = '
暂无附件
'; + return; + } + attachments.forEach(function (attachment) { + var item = document.createElement("div"); + item.className = "attachment-item"; + item.setAttribute("data-attachment-id", attachment.id); + item.innerHTML = + "
" + + escapeHtml(attachment.original_name) + + "v" + + attachment.version_no + + " · " + + attachment.file_size + + " bytes · " + + escapeHtml(attachment.upload_status) + + "
" + + (attachment.is_active ? "active" : ""); + attachmentList.appendChild(item); + }); + } + + async function refreshAttachments() { + var conversationId = currentConversationId(); + if (!conversationId || !summaryPanel) { + return; + } + var response = await fetch(templateUrl("data-attachment-url-template", "__conversation_id__", conversationId)); + if (!response.ok) { + return; + } + var payload = await response.json(); + renderAttachments(payload.attachments || []); + } + + async function uploadFiles(files) { + var conversationId = currentConversationId(); + if (!conversationId || !files.length || !summaryPanel) { + if (uploadStatus) { + uploadStatus.textContent = "请先创建或选择一个对话。"; + } + return; + } + var data = new FormData(); + Array.prototype.forEach.call(files, function (file) { + data.append("files", file); + }); + var csrf = new FormData(composer).get("csrfmiddlewaretoken"); + if (uploadStatus) { + uploadStatus.textContent = "正在上传 " + files.length + " 个文件..."; + } + try { + var response = await fetch(templateUrl("data-attachment-url-template", "__conversation_id__", conversationId), { + method: "POST", + headers: { "X-CSRFToken": csrf }, + body: data, + }); + if (!response.ok) { + throw new Error("上传失败。"); + } + var payload = await response.json(); + renderAttachments(payload.attachments || []); + if (uploadStatus) { + uploadStatus.textContent = "上传完成,可发送自动汇总提示词。"; + } + await refreshAttachments(); + } catch (error) { + if (uploadStatus) { + uploadStatus.textContent = "上传失败,请重试。"; + } + } + } + + function ensureWorkflowCard(batch) { + if (!workflowCardList || !batch) { + return null; + } + var empty = workflowCardList.querySelector(".panel-empty"); + if (empty) { + empty.remove(); + } + var workflow_type = batch.workflow_type || "file_summary"; + var card = workflowCardList.querySelector( + '[data-batch-id="' + batch.batch_id + '"][data-workflow-type="' + workflow_type + '"]' + ); + if (card) { + return card; + } + card = document.createElement("article"); + card.className = "workflow-card"; + card.setAttribute("data-batch-id", batch.batch_id); + card.setAttribute("data-workflow-type", workflow_type); + card.innerHTML = + "
" + + escapeHtml(batch.batch_no || "文件汇总") + + 'running
    '; + workflowCardList.prepend(card); + refreshWorkflowBatchCarousel(0); + return card; + } + + function workflowCards() { + if (!workflowCardList) { + return []; + } + return Array.prototype.slice.call(workflowCardList.querySelectorAll(".workflow-card")); + } + + function ensureWorkflowBatchControls() { + if (!workflowCardList || workflowCardList.querySelector(".workflow-batch-controls")) { + return; + } + var controls = document.createElement("div"); + controls.className = "workflow-batch-controls"; + controls.innerHTML = + '' + + '
    ' + + ''; + workflowCardList.appendChild(controls); + } + + function selectWorkflowBatchIndex(index) { + var cards = workflowCards(); + if (!workflowCardList || !cards.length) { + return; + } + var safeIndex = Math.max(0, Math.min(index, cards.length - 1)); + workflowCardList.setAttribute("data-active-index", safeIndex); + cards.forEach(function (card, cardIndex) { + var isActive = cardIndex === safeIndex; + card.classList.toggle("active", isActive); + card.setAttribute("data-workflow-index", cardIndex); + card.setAttribute("aria-hidden", isActive ? "false" : "true"); + }); + var dots = workflowCardList.querySelector(".workflow-batch-dots"); + if (!dots) { + return; + } + dots.querySelectorAll("[data-workflow-index-dot]").forEach(function (dot) { + var dotIndex = parseInt(dot.getAttribute("data-workflow-index-dot"), 10); + var isActive = dotIndex === safeIndex; + dot.classList.toggle("active", isActive); + dot.setAttribute("aria-current", isActive ? "true" : "false"); + }); + } + + function refreshWorkflowBatchCarousel(preferredIndex) { + var cards = workflowCards(); + if (!workflowCardList || !cards.length) { + return; + } + workflowCardList.classList.add("workflow-batch-carousel"); + ensureWorkflowBatchControls(); + var dots = workflowCardList.querySelector(".workflow-batch-dots"); + if (dots) { + dots.innerHTML = ""; + cards.forEach(function (card, index) { + card.setAttribute("data-workflow-index", index); + var title = card.querySelector("strong"); + var dot = document.createElement("button"); + dot.type = "button"; + dot.className = "workflow-batch-dot"; + dot.setAttribute("data-workflow-index-dot", index); + dot.setAttribute("aria-label", "查看" + (title ? title.textContent.trim() : "工作流") + "状态"); + dots.appendChild(dot); + }); + } + var activeIndex = + typeof preferredIndex === "number" + ? preferredIndex + : parseInt(workflowCardList.getAttribute("data-active-index") || "0", 10); + if (Number.isNaN(activeIndex)) { + activeIndex = 0; + } + selectWorkflowBatchIndex(activeIndex); + } + + function ensureConditionConfirmationCard(confirmation) { + if (!chatScroll || !confirmation || !confirmation.candidates) { + return; + } + var cardId = "condition-confirmation-" + confirmation.batch_id; + removeStaleConditionConfirmationCards(cardId); + if (document.getElementById(cardId)) { + return; + } + var article = document.createElement("article"); + article.className = "message assistant"; + article.id = cardId; + article.setAttribute("data-condition-confirmation-card", ""); + article.setAttribute("data-node-label", "AI 适用条件确认"); + + var avatar = document.createElement("div"); + avatar.className = "message-avatar"; + avatar.textContent = "AI"; + + var bubble = document.createElement("div"); + bubble.className = "message-bubble"; + var form = document.createElement("form"); + form.className = "condition-confirm-form"; + form.setAttribute("data-condition-confirm-form", ""); + form.setAttribute("data-batch-id", confirmation.batch_id); + form.setAttribute("data-confirm-url", confirmation.confirm_url); + form.innerHTML = + '' + + "适用条件确认" + + "

    请确认 " + + escapeHtml(confirmation.batch_no || "") + + " 的产品类别、注册类型和临床评价路径,确认后我会继续法规核查。

    " + + renderConditionFields(confirmation.candidates) + + '' + + '

    '; + bubble.appendChild(form); + article.appendChild(avatar); + article.appendChild(bubble); + chatScroll.appendChild(article); + bindConditionConfirmForms(); + scrollChatToBottom(); + } + + function removeStaleConditionConfirmationCards(activeCardId) { + document.querySelectorAll("[data-condition-confirmation-card]").forEach(function (card) { + if (card.id !== activeCardId) { + card.remove(); + } + }); + } + + function renderConditionFields(candidates) { + var html = ""; + Object.keys(candidates || {}).forEach(function (field) { + var config = candidates[field] || {}; + html += ""; + }); + return html; + } + + function notificationLabel(notification) { + if (!notification) { + return "暂无飞书通知记录"; + } + return notification.status_label || notification.send_status || "飞书通知状态未知"; + } + + function renderNotificationSummary(card, notification) { + var panel = card.querySelector(".workflow-notification"); + if (!panel) { + panel = document.createElement("p"); + panel.className = "workflow-notification"; + card.insertBefore(panel, card.querySelector("ol")); + } + var text = notificationLabel(notification); + if (notification && notification.receiver) { + text += " · " + notification.receiver; + } + if (notification && notification.sent_at) { + text += " · " + notification.sent_at; + } + if (notification && notification.error_message) { + text += " · " + notification.error_message; + } + panel.textContent = text; + panel.setAttribute("data-notification-status", notification ? notification.send_status || "" : "none"); + } + + async function refreshWorkflowCard(batchId, workflow_type) { + if (!summaryPanel || !batchId) { + return ""; + } + var response; + try { + response = await fetch(statusUrlForWorkflow(workflow_type || "file_summary", batchId), { + cache: "no-store", + }); + } catch (error) { + console.error("Workflow status refresh failed", { batchId: batchId, error: error }); + return ""; + } + if (!response.ok) { + console.error("Workflow status refresh returned non-OK", { batchId: batchId, status: response.status }); + return ""; + } + var payload = await response.json(); + if (payload.condition_confirmation) { + ensureConditionConfirmationCard(payload.condition_confirmation); + } + var card = ensureWorkflowCard({ + batch_id: payload.batch.id, + batch_no: payload.batch.batch_no, + workflow_type: payload.batch.workflow_type || workflow_type || "file_summary", + }); + if (!card) { + return payload.batch.status || ""; + } + var status = card.querySelector(".workflow-status"); + status.textContent = payload.batch.status; + status.className = "workflow-status status-" + payload.batch.status; + var batchError = card.querySelector(".workflow-error"); + if (payload.batch.error_message) { + if (!batchError) { + batchError = document.createElement("p"); + batchError.className = "workflow-error"; + card.insertBefore(batchError, card.querySelector("ol")); + } + batchError.textContent = payload.batch.error_message; + } else if (batchError) { + batchError.remove(); + } + var riskSummary = card.querySelector(".workflow-risk-summary"); + if (payload.batch.risk_summary_text) { + if (!riskSummary) { + riskSummary = document.createElement("p"); + riskSummary.className = "workflow-risk-summary"; + card.insertBefore(riskSummary, card.querySelector("ol")); + } + riskSummary.textContent = payload.batch.risk_summary_text; + } else if (riskSummary) { + riskSummary.remove(); + } + renderNotificationSummary(card, payload.latest_notification); + var list = card.querySelector("ol"); + list.innerHTML = ""; + (payload.nodes || []).forEach(function (node) { + var item = document.createElement("li"); + item.className = "node-status status-" + node.status; + item.setAttribute("data-node-code", node.node_code); + item.innerHTML = + '
    ' + + escapeHtml(node.node_name) + + "" + + (node.message ? "" + escapeHtml(node.message) + "" : "") + + "
    " + + node.progress + + "%"; + list.appendChild(item); + }); + refreshWorkflowBatchCarousel(); + return payload.batch.status || ""; + } + + function bindWorkflowBatchCarouselControls() { + if (!workflowCardList) { + return; + } + workflowCardList.addEventListener("click", function (event) { + var cards = workflowCards(); + if (!cards.length) { + return; + } + var actionButton = event.target.closest("[data-workflow-action]"); + var dotButton = event.target.closest("[data-workflow-index-dot]"); + var currentIndex = parseInt(workflowCardList.getAttribute("data-active-index") || "0", 10); + if (Number.isNaN(currentIndex)) { + currentIndex = 0; + } + if (actionButton) { + var nextIndex = + actionButton.getAttribute("data-workflow-action") === "next" + ? (currentIndex + 1) % cards.length + : (currentIndex - 1 + cards.length) % cards.length; + selectWorkflowBatchIndex(nextIndex); + } else if (dotButton) { + selectWorkflowBatchIndex(parseInt(dotButton.getAttribute("data-workflow-index-dot"), 10)); + } + }); + } + + function isWorkflowTerminalStatus(status) { + return status === "success" || status === "partial_success" || status === "failed"; + } + + function workflowTimerKey(batchId, workflow_type) { + return (workflow_type || "file_summary") + ":" + batchId; + } + + function stopWorkflowPolling(batchId, workflow_type) { + var key = workflowTimerKey(batchId, workflow_type); + if (!workflowPollingTimers[key]) { + return; + } + window.clearInterval(workflowPollingTimers[key]); + delete workflowPollingTimers[key]; + } + + function startWorkflowPolling(batchId, workflow_type) { + var card = workflowCardList ? workflowCardList.querySelector('[data-batch-id="' + batchId + '"]') : null; + workflow_type = workflow_type || (card ? card.getAttribute("data-workflow-type") || "file_summary" : "file_summary"); + var key = workflowTimerKey(batchId, workflow_type); + if (!batchId || workflowPollingTimers[key]) { + return; + } + workflowPollingTimers[key] = window.setInterval(async function () { + var status = await refreshWorkflowCard(batchId, workflow_type); + if (isWorkflowTerminalStatus(status)) { + refreshConversationMessages(); + stopWorkflowPolling(batchId, workflow_type); + } + }, WORKFLOW_POLL_INTERVAL_MS); + refreshWorkflowCard(batchId, workflow_type).then(function (status) { + if (isWorkflowTerminalStatus(status)) { + refreshConversationMessages(); + stopWorkflowPolling(batchId, workflow_type); + } + }); + } + + function refreshRunningWorkflowCards() { + if (!workflowCardList) { + return; + } + workflowCardList.querySelectorAll(".workflow-card").forEach(function (card) { + var batchId = card.getAttribute("data-batch-id"); + var workflow_type = card.getAttribute("data-workflow-type") || "file_summary"; + var status = card.querySelector(".workflow-status"); + var statusText = status ? status.textContent.trim() : ""; + if (!isWorkflowTerminalStatus(statusText)) { + startWorkflowPolling(batchId, workflow_type); + } + }); + } + + function bindConditionConfirmForms() { + document.querySelectorAll("[data-condition-confirm-form]").forEach(function (form) { + if (form.dataset.bound === "true") { + return; + } + form.dataset.bound = "true"; + form.addEventListener("submit", async function (event) { + event.preventDefault(); + var batchId = form.getAttribute("data-batch-id"); + var status = form.querySelector("[data-condition-confirm-status]"); + var submitButton = form.querySelector('button[type="submit"]'); + var formData = new FormData(form); + var conditions = {}; + formData.forEach(function (value, key) { + if (key !== "csrfmiddlewaretoken") { + conditions[key] = value; + } + }); + if (submitButton) { + submitButton.disabled = true; + } + if (status) { + status.textContent = "正在恢复法规核查..."; + } + try { + var response = await fetch(form.getAttribute("data-confirm-url"), { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": formData.get("csrfmiddlewaretoken"), + }, + body: JSON.stringify({ conditions: conditions }), + }); + if (!response.ok) { + throw new Error("确认失败。"); + } + if (status) { + status.textContent = "已确认,工作流继续执行。"; + } + form.classList.add("confirmed"); + startWorkflowPolling(batchId, "regulatory_review"); + await refreshWorkflowCard(batchId, "regulatory_review"); + } catch (error) { + if (status) { + status.textContent = "确认失败,请稍后重试。"; + } + if (submitButton) { + submitButton.disabled = false; + } + } + }); + }); + } + + function bindRectificationActionButtons() { + document.querySelectorAll("[data-rectification-action]").forEach(function (button) { + if (button.dataset.bound === "true") { + return; + } + button.dataset.bound = "true"; + button.addEventListener("click", function () { + if (!promptInput) { + return; + } + var action = button.getAttribute("data-rectification-action"); + var batchNo = button.getAttribute("data-batch-no") || ""; + if (action === "full-review") { + promptInput.value = "请基于新的文件汇总批次,对法规核查批次 " + batchNo + " 发起整包复核,并先确认使用哪个补充批次。"; + } else { + promptInput.value = "请对法规核查批次 " + batchNo + " 的缺失项发起复核,并先确认 issue_ids 和补充文件汇总批次。"; + } + promptInput.focus(); + }); + }); + } + + function bindPromptTemplateButtons() { + document.querySelectorAll("[data-prompt-template]").forEach(function (button) { + if (button.dataset.bound === "true") { + return; + } + button.dataset.bound = "true"; + button.addEventListener("click", function () { + if (!promptInput) { + return; + } + var template = button.getAttribute("data-prompt-template") || ""; + promptInput.value = template; + promptInput.focus(); + if (typeof promptInput.setSelectionRange === "function") { + promptInput.setSelectionRange(promptInput.value.length, promptInput.value.length); + } + }); + }); + } + + async function streamChat(event) { + event.preventDefault(); + if (!composer || !promptInput || !sendButton || !chatStage) { + return; + } + + var prompt = promptInput.value.trim(); + if (!prompt || sendButton.disabled) { + return; + } + + sendButton.disabled = true; + sendButton.textContent = "生成中..."; + + var formData = new FormData(composer); + var csrfToken = formData.get("csrfmiddlewaretoken"); + var streamUrl = chatStage.getAttribute("data-stream-url"); + var tempUserId = "message-user-temp-" + Date.now(); + var tempAssistantId = "message-ai-temp-" + (Date.now() + 1); + var userLabel = "用户 " + (document.querySelectorAll(".message").length + 1); + + setConversationTitle((prompt || "").slice(0, 24)); + var userMessage = createMessage("user", prompt, tempUserId, userLabel); + var assistantMessage = createMessage("assistant", "", tempAssistantId, ""); + assistantMessage.bubble.classList.add("streaming"); + appendNode(userMessage.article.id, userLabel, false); + scrollChatToBottom(); + promptInput.value = ""; + + try { + var response = await fetch(streamUrl, { + method: "POST", + headers: { + "X-CSRFToken": csrfToken, + }, + body: formData, + }); + + if (!response.ok || !response.body) { + throw new Error("流式请求失败。"); + } + + var reader = response.body.getReader(); + var decoder = new TextDecoder("utf-8"); + var buffer = ""; + var assistantText = ""; + + while (true) { + var readResult = await reader.read(); + if (readResult.done) { + break; + } + + buffer += decoder.decode(readResult.value, { stream: true }); + var events = buffer.split("\n\n"); + buffer = events.pop(); + + events.forEach(function (frame) { + var eventName = ""; + var dataText = ""; + frame.split("\n").forEach(function (line) { + if (line.indexOf("event:") === 0) { + eventName = line.slice(6).trim(); + } + if (line.indexOf("data:") === 0) { + dataText += line.slice(5).trim(); + } + }); + + if (!eventName || !dataText) { + return; + } + + var payload; + try { + payload = JSON.parse(dataText); + } catch (error) { + console.error("SSE frame parse failed", { error: error, frame: frame }); + return; + } + if (eventName === "meta") { + if (payload.user_message_id) { + userMessage.article.id = "message-" + payload.user_message_id; + userMessage.article.setAttribute("data-message-id", payload.user_message_id); + latestMessageId = Math.max(latestMessageId, payload.user_message_id); + } + if (payload.conversation_id) { + conversationIdInput.value = payload.conversation_id; + window.history.replaceState({}, "", "/?conversation=" + payload.conversation_id); + } + if (payload.title) { + setConversationTitle(payload.title); + updateSidebarConversation(payload.conversation_id, payload.title); + } + } else if (eventName === "chunk") { + assistantText += payload.delta || ""; + assistantMessage.text.innerHTML = renderAssistantContent(assistantText); + scrollChatToBottom(); + } else if (eventName === "replace") { + assistantText = payload.content || ""; + assistantMessage.text.innerHTML = renderAssistantContent(assistantText); + scrollChatToBottom(); + } else if (eventName === "error") { + assistantText = payload.message || "模型调用失败。"; + assistantMessage.text.innerHTML = renderAssistantContent(assistantText); + } else if (eventName === "workflow_started") { + ensureWorkflowCard(payload); + startWorkflowPolling(payload.batch_id, payload.workflow_type); + } else if (eventName === "done") { + if (payload.assistant_message_id) { + assistantMessage.article.id = "message-" + payload.assistant_message_id; + assistantMessage.article.setAttribute("data-message-id", payload.assistant_message_id); + latestMessageId = Math.max(latestMessageId, payload.assistant_message_id); + } + if (payload.title) { + setConversationTitle(payload.title); + updateSidebarConversation(payload.conversation_id, payload.title); + } + } + }); + } + + assistantMessage.bubble.classList.remove("streaming"); + syncNodeRailVisibility(); + bindNodeAnchorClicks(); + setActiveNode(); + scrollChatToBottom(); + } catch (error) { + assistantMessage.bubble.classList.remove("streaming"); + assistantMessage.text.textContent = "请求失败,请稍后重试。"; + } finally { + sendButton.disabled = false; + sendButton.textContent = "发送"; + promptInput.focus(); + } + } + + function bindPromptKeyboardShortcuts() { + if (!promptInput || !composer) { + return; + } + promptInput.addEventListener("keydown", function (event) { + if (event.key === "Enter" && !event.ctrlKey) { + event.preventDefault(); + if (typeof composer.requestSubmit === "function") { + composer.requestSubmit(); + } else { + composer.dispatchEvent(new Event("submit", { cancelable: true })); + } + } + }); + } + + function bindConversationDeleteButtons() { + var list = document.querySelector(".history-list"); + if (!list) { + return; + } + list.addEventListener("click", function (event) { + var button = event.target.closest("[data-conversation-delete]"); + if (!button) { + return; + } + event.preventDefault(); + event.stopPropagation(); + var item = button.closest(".history-item"); + deleteConversation(item).catch(function () { + window.alert("删除对话失败,请稍后重试。"); + }); + }); + } + + syncNodeRailVisibility(); + syncLatestMessageIdFromDom(); + bindNodeAnchorClicks(); + renderExistingAssistantMessages(); + refreshWorkflowBatchCarousel(0); + bindWorkflowBatchCarouselControls(); + bindConditionConfirmForms(); + bindRectificationActionButtons(); + bindPromptTemplateButtons(); + bindConversationDeleteButtons(); + refreshRunningWorkflowCards(); + + if (chatScroll) { + chatScroll.addEventListener("scroll", setActiveNode, { passive: true }); + setActiveNode(); + } + + if (composer) { + composer.addEventListener("submit", streamChat); + } + bindPromptKeyboardShortcuts(); + + if (uploadDropzone && attachmentInput) { + uploadDropzone.addEventListener("click", function () { + attachmentInput.click(); + }); + uploadDropzone.addEventListener("dragover", function (event) { + event.preventDefault(); + uploadDropzone.classList.add("dragging"); + }); + uploadDropzone.addEventListener("dragleave", function () { + uploadDropzone.classList.remove("dragging"); + }); + uploadDropzone.addEventListener("drop", function (event) { + event.preventDefault(); + uploadDropzone.classList.remove("dragging"); + uploadFiles(event.dataTransfer.files); + }); + attachmentInput.addEventListener("change", function () { + uploadFiles(attachmentInput.files); + attachmentInput.value = ""; + }); + } + + window.addEventListener("resize", syncSidebarState); + syncSidebarState(); +})(); diff --git a/static/js/attachment_manager.js b/static/js/attachment_manager.js new file mode 100644 index 0000000..0b565c3 --- /dev/null +++ b/static/js/attachment_manager.js @@ -0,0 +1,147 @@ +(function () { + var page = document.querySelector(".attachment-manager-page"); + if (!page) { + return; + } + + var conversationSelect = document.getElementById("attachmentConversationSelect"); + var uploadDropzone = document.getElementById("managerUploadDropzone"); + var attachmentInput = document.getElementById("managerAttachmentInput"); + var uploadStatus = document.getElementById("managerUploadStatus"); + var searchInput = document.getElementById("attachmentSearch"); + var table = document.getElementById("attachmentManagerTable"); + + function csrfToken() { + var cookie = document.cookie.split("; ").find(function (item) { + return item.indexOf("csrftoken=") === 0; + }); + return cookie ? decodeURIComponent(cookie.split("=")[1]) : ""; + } + + function selectedConversationUrl(id) { + return id ? "/attachments/?conversation=" + encodeURIComponent(id) : "/attachments/"; + } + + async function patchAttachment(row, payload) { + var response = await fetch(row.getAttribute("data-update-url"), { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken(), + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error("附件更新失败。"); + } + return response.json(); + } + + async function deleteAttachment(row) { + var response = await fetch(row.getAttribute("data-update-url"), { + method: "DELETE", + headers: { "X-CSRFToken": csrfToken() }, + }); + if (!response.ok) { + throw new Error("附件删除失败。"); + } + } + + async function uploadFiles(files) { + if (!uploadDropzone || !files || !files.length) { + return; + } + var formData = new FormData(); + Array.prototype.forEach.call(files, function (file) { + formData.append("files", file); + }); + if (uploadStatus) { + uploadStatus.textContent = "上传中..."; + } + try { + var response = await fetch(uploadDropzone.getAttribute("data-upload-url"), { + method: "POST", + headers: { "X-CSRFToken": csrfToken() }, + body: formData, + }); + if (!response.ok) { + throw new Error("上传失败。"); + } + window.location.reload(); + } catch (error) { + if (uploadStatus) { + uploadStatus.textContent = "上传失败,请重试。"; + } + } + } + + if (conversationSelect) { + conversationSelect.addEventListener("change", function () { + window.location.href = selectedConversationUrl(conversationSelect.value); + }); + } + + if (uploadDropzone && attachmentInput) { + uploadDropzone.addEventListener("click", function () { + attachmentInput.click(); + }); + uploadDropzone.addEventListener("dragover", function (event) { + event.preventDefault(); + uploadDropzone.classList.add("dragging"); + }); + uploadDropzone.addEventListener("dragleave", function () { + uploadDropzone.classList.remove("dragging"); + }); + uploadDropzone.addEventListener("drop", function (event) { + event.preventDefault(); + uploadDropzone.classList.remove("dragging"); + uploadFiles(event.dataTransfer.files); + }); + attachmentInput.addEventListener("change", function () { + uploadFiles(attachmentInput.files); + attachmentInput.value = ""; + }); + } + + if (searchInput && table) { + searchInput.addEventListener("input", function () { + var keyword = searchInput.value.trim().toLowerCase(); + table.querySelectorAll("tbody tr[data-attachment-id]").forEach(function (row) { + var name = (row.querySelector(".attachment-name") || row).textContent.toLowerCase(); + row.hidden = keyword && name.indexOf(keyword) === -1; + }); + }); + } + + if (table) { + table.addEventListener("click", async function (event) { + var actionButton = event.target.closest("[data-attachment-action]"); + if (!actionButton) { + return; + } + var row = actionButton.closest("tr[data-attachment-id]"); + if (!row) { + return; + } + var action = actionButton.getAttribute("data-attachment-action"); + try { + if (action === "edit") { + var nameCell = row.querySelector(".attachment-name"); + var nextName = window.prompt("请输入新的附件展示名", nameCell ? nameCell.textContent.trim() : ""); + if (nextName) { + await patchAttachment(row, { original_name: nextName }); + window.location.reload(); + } + } else if (action === "toggle") { + await patchAttachment(row, { is_active: actionButton.textContent.trim() === "启用" }); + window.location.reload(); + } else if (action === "delete" && window.confirm("确认删除该附件?")) { + await deleteAttachment(row); + window.location.reload(); + } + } catch (error) { + window.alert(error.message || "附件操作失败。"); + } + }); + } +})(); diff --git a/static/js/knowledge_base.js b/static/js/knowledge_base.js new file mode 100644 index 0000000..cb756db --- /dev/null +++ b/static/js/knowledge_base.js @@ -0,0 +1,304 @@ +(function () { + var page = document.querySelector(".knowledge-page"); + if (!page) { + return; + } + + var documentForm = document.getElementById("knowledgeDocumentForm"); + var documentStatus = document.getElementById("knowledgeDocumentStatus"); + var documentTable = document.getElementById("knowledgeDocumentTable"); + var documentSearch = document.getElementById("knowledgeDocumentSearch"); + var searchForm = document.getElementById("knowledgeSearchForm"); + var queryInput = document.getElementById("knowledgeSearchQuery"); + var results = document.getElementById("knowledgeSearchResults"); + var sourceSearch = document.getElementById("knowledgeSourceSearch"); + var sourceTable = document.getElementById("knowledgeSourceTable"); + var documentFileInput = document.getElementById("knowledgeDocumentFile"); + var uploadDropzone = document.getElementById("knowledgeUploadDropzone"); + var rebuildButton = document.getElementById("knowledgeRebuildIndexButton"); + var rebuildStatus = document.getElementById("knowledgeRebuildStatus"); + + function csrfToken() { + var cookie = document.cookie.split("; ").find(function (item) { + return item.indexOf("csrftoken=") === 0; + }); + return cookie ? decodeURIComponent(cookie.split("=")[1]) : ""; + } + + function escapeHtml(value) { + return String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + async function patchDocument(row, payload) { + var response = await fetch(row.getAttribute("data-detail-url"), { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken(), + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error("知识库材料更新失败。"); + } + return response.json(); + } + + async function deleteDocument(row) { + var response = await fetch(row.getAttribute("data-detail-url"), { + method: "DELETE", + headers: { "X-CSRFToken": csrfToken() }, + }); + if (!response.ok) { + throw new Error("知识库材料删除失败。"); + } + } + + async function indexDocument(row) { + var response = await fetch(row.getAttribute("data-index-url"), { + method: "POST", + headers: { "X-CSRFToken": csrfToken() }, + }); + if (!response.ok) { + throw new Error("知识库材料解析入库失败。"); + } + return response.json(); + } + + async function rebuildIndex() { + var response = await fetch(page.getAttribute("data-rebuild-url"), { + method: "POST", + headers: { "X-CSRFToken": csrfToken() }, + }); + if (!response.ok) { + throw new Error("法规索引重建失败。"); + } + return response.json(); + } + + function renderResults(payload) { + if (!results) { + return; + } + if (payload.error_message) { + results.innerHTML = '

    ' + escapeHtml(payload.error_message) + "

    "; + return; + } + if (!payload.results || !payload.results.length) { + results.innerHTML = '

    未检索到依据片段。

    '; + return; + } + results.innerHTML = payload.results + .map(function (item, index) { + return [ + '
    ', + "
    结果 " + (index + 1) + "" + escapeHtml(item.source || "法规材料") + "
    ", + "

    " + escapeHtml(item.text || "").slice(0, 600) + "

    ", + item.score === null || item.score === undefined ? "" : "score: " + escapeHtml(item.score) + "", + "
    ", + ].join(""); + }) + .join(""); + } + + if (documentForm) { + documentForm.addEventListener("submit", async function (event) { + event.preventDefault(); + var formData = new FormData(documentForm); + if (documentStatus) { + documentStatus.textContent = "上传并解析入库中..."; + } + try { + var response = await fetch(page.getAttribute("data-document-url"), { + method: "POST", + headers: { "X-CSRFToken": csrfToken() }, + body: formData, + }); + if (!response.ok) { + throw new Error("新增材料失败。"); + } + window.location.reload(); + } catch (error) { + if (documentStatus) { + documentStatus.textContent = error.message || "新增材料失败。"; + } + } + }); + } + + if (documentFileInput && documentStatus) { + documentFileInput.addEventListener("change", function () { + var file = documentFileInput.files && documentFileInput.files[0]; + documentStatus.textContent = file + ? "已选择:" + file.name + : "上传后会进入当前账号的全局知识库。"; + }); + } + + if (uploadDropzone && documentFileInput) { + uploadDropzone.addEventListener("click", function () { + documentFileInput.click(); + }); + uploadDropzone.addEventListener("keydown", function (event) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + documentFileInput.click(); + } + }); + ["dragenter", "dragover"].forEach(function (eventName) { + uploadDropzone.addEventListener(eventName, function (event) { + event.preventDefault(); + uploadDropzone.classList.add("dragging"); + }); + }); + ["dragleave", "drop"].forEach(function (eventName) { + uploadDropzone.addEventListener(eventName, function (event) { + event.preventDefault(); + uploadDropzone.classList.remove("dragging"); + }); + }); + uploadDropzone.addEventListener("drop", function (event) { + var files = event.dataTransfer && event.dataTransfer.files; + if (!files || !files.length) { + return; + } + documentFileInput.files = files; + documentFileInput.dispatchEvent(new Event("change", { bubbles: true })); + }); + } + + if (documentTable) { + documentTable.addEventListener("click", async function (event) { + var button = event.target.closest("[data-kb-action]"); + if (!button) { + return; + } + var row = button.closest("tr[data-document-id]"); + if (!row) { + return; + } + var action = button.getAttribute("data-kb-action"); + try { + if (action === "edit") { + var nameCell = row.querySelector(".attachment-name"); + var nextName = window.prompt("请输入新的材料名称", nameCell ? nameCell.textContent.trim() : ""); + if (nextName) { + await patchDocument(row, { display_name: nextName }); + window.location.reload(); + } + } else if (action === "toggle") { + await patchDocument(row, { is_active: button.textContent.trim() === "启用" }); + window.location.reload(); + } else if (action === "index") { + button.disabled = true; + button.textContent = "解析中"; + await indexDocument(row); + window.location.reload(); + } else if (action === "delete" && window.confirm("确认删除该知识库材料?")) { + await deleteDocument(row); + window.location.reload(); + } + } catch (error) { + window.alert(error.message || "知识库材料操作失败。"); + } + }); + } + + async function handleRebuild(trigger) { + if (!page.getAttribute("data-rebuild-url")) { + return; + } + var originalText = trigger ? trigger.textContent : ""; + if (trigger) { + trigger.disabled = true; + trigger.textContent = "入库中"; + } + if (rebuildButton && trigger !== rebuildButton) { + rebuildButton.disabled = true; + } + if (rebuildStatus) { + rebuildStatus.textContent = "正在重建法规 RAG 索引..."; + } + try { + var payload = await rebuildIndex(); + if (rebuildStatus) { + rebuildStatus.textContent = "重建完成,入库片段 " + (payload.chunk_count || 0) + " 个。"; + } + window.setTimeout(function () { + window.location.reload(); + }, 600); + } catch (error) { + if (rebuildStatus) { + rebuildStatus.textContent = error.message || "法规索引重建失败。"; + } + if (trigger) { + trigger.disabled = false; + trigger.textContent = originalText; + } + if (rebuildButton) { + rebuildButton.disabled = false; + } + } + } + + if (rebuildButton) { + rebuildButton.addEventListener("click", function () { + handleRebuild(rebuildButton); + }); + } + + if (sourceTable) { + sourceTable.addEventListener("click", function (event) { + var button = event.target.closest("[data-source-action='index']"); + if (!button) { + return; + } + handleRebuild(button); + }); + } + + if (searchForm && queryInput) { + searchForm.addEventListener("submit", async function (event) { + event.preventDefault(); + var query = queryInput.value.trim(); + if (!query) { + renderResults({ error_message: "请输入检索问题。" }); + return; + } + results.innerHTML = '

    检索中...

    '; + try { + var response = await fetch(page.getAttribute("data-search-url"), { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken(), + }, + body: JSON.stringify({ query: query }), + }); + renderResults(await response.json()); + } catch (error) { + renderResults({ error_message: "检索失败,请稍后重试。" }); + } + }); + } + + function bindTableSearch(input, table, selector) { + if (!input || !table) { + return; + } + input.addEventListener("input", function () { + var keyword = input.value.trim().toLowerCase(); + table.querySelectorAll(selector).forEach(function (row) { + row.hidden = keyword && row.textContent.toLowerCase().indexOf(keyword) === -1; + }); + }); + } + + bindTableSearch(documentSearch, documentTable, "tbody tr[data-document-id]"); + bindTableSearch(sourceSearch, sourceTable, "tbody tr[data-source-name]"); +})(); diff --git a/templates/attachment_manager.html b/templates/attachment_manager.html new file mode 100644 index 0000000..581eb24 --- /dev/null +++ b/templates/attachment_manager.html @@ -0,0 +1,139 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}附件管理 - DEMO-AGENT V2{% endblock %} +{% block body_class %}app-body{% endblock %} + +{% block content %} +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    +

    附件管理

    +

    附件管理

    +

    管理各对话下上传的审核资料、版本、状态和下载。

    +
    +
    + + + {% if selected_conversation %} + 返回对话 + {% endif %} +
    +
    + + {% if selected_conversation %} +
    +
    +
    +

    上传附件

    + {{ selected_conversation.title|default:"新对话" }} +
    +
    + + 拖拽文件到这里 + 支持 doc、docx、xls、xlsx、ppt、pptx、pdf、zip、7z、rar +
    +

    上传后会归属到当前选择的对话。

    +
    + +
    +
    +

    附件列表

    + +
    +
    + + + + + + + + + + + + + {% for attachment in attachments %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    状态文件名版本大小上传时间操作
    {% if attachment.is_active %}启用{% else %}禁用{% endif %}{{ attachment.original_name }}v{{ attachment.version_no }}{{ attachment.file_size }} bytes{{ attachment.created_at|date:"Y-m-d H:i" }} + 下载 + + + +
    当前对话暂无附件
    +
    +
    +
    + {% else %} +
    +

    请选择一个对话查看附件

    +

    通过上方下拉框选择对话后,可上传、下载、编辑、启用禁用或删除附件。

    +
    + {% endif %} +
    +
    +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html index 1f915fc..adeff1b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,351 +1,14 @@ +{% load static %} - {% block title %}Universal Agent Demo Framework{% endblock %} - + {% block title %}DEMO-AGENT V2{% endblock %} + - -
    -
    -
    -

    Universal Agent Demo Framework

    -

    面向复试演示的可配置 AI Agent 单体系统

    -
    - -
    - {% if messages %} -
    - {% for message in messages %} -
    {{ message }}
    - {% endfor %} -
    - {% endif %} - {% block content %}{% endblock %} -
    + + {% block content %}{% endblock %} + {% block scripts %}{% endblock %} diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..f5ba5eb --- /dev/null +++ b/templates/home.html @@ -0,0 +1,372 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}审核智能体 - DEMO-AGENT V2{% endblock %} +{% block body_class %}app-body{% endblock %} + +{% block content %} +
    +
    + + +
    +
    + + +
    +
    +
    + +
    + + +
    +
    +
    +
    + {% if current_conversation %} +
    +
    +

    审核智能体

    +

    {{ current_conversation.title|default:"新对话" }}

    +
    + 最后更新 {{ current_conversation.updated_at|date:"Y-m-d H:i" }} +
    + + {% for message in messages %} +
    +
    + {% if message.role == "assistant" %}AI{% else %}{{ request.user.username|slice:":1"|upper }}{% endif %} +
    +
    + {% if message.role == "assistant" %} +
    + + {% else %} +

    {{ message.content|linebreaksbr }}

    + {% endif %} +
    +
    + {% endfor %} + {% if condition_confirmation %} +
    +
    AI
    +
    +
    + {% csrf_token %} + 适用条件确认 +

    请确认 {{ condition_confirmation.batch_no }} 的产品类别、注册类型和临床评价路径,确认后我会继续法规核查。

    + {% for field, config in condition_confirmation.candidates.items %} + + {% endfor %} + +

    +
    +
    +
    + {% endif %} + {% else %} +
    +

    审核智能体

    +

    开始新的审核对话

    +

    输入资料疑点、法规条款、说明书问题或风险项,系统会为你保留真实会话记录。

    +
    + {% endif %} +
    + +
    + +
    +
    + {% csrf_token %} + + + + +
    +
    + + + + +
    + +
    +
    +
    +
    +
    + + +
    +
    +{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/templates/knowledge_base.html b/templates/knowledge_base.html new file mode 100644 index 0000000..aa4039c --- /dev/null +++ b/templates/knowledge_base.html @@ -0,0 +1,219 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}知识库管理 - DEMO-AGENT V2{% endblock %} +{% block body_class %}app-body{% endblock %} + +{% block content %} +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    +

    知识库管理

    +

    知识库管理

    +

    管理当前账号所有对话可调用的法规、制度、模板和审查依据。

    +
    +
    + {{ knowledge_base.status.label }} + 返回对话 +
    +
    + +
    + + +
    +
    +
    +

    知识库材料列表

    + +
    +
    + + + + + + + + + + + + + + {% for document in knowledge_base.managed_documents %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    状态材料名称文件名大小入库状态更新时间操作
    {% if document.is_active %}启用{% else %}停用{% endif %}{{ document.display_name }}{{ document.original_name }}{{ document.file_size }} bytes{{ document.indexed_label }}{{ document.updated_at|slice:":19" }} + + + + +
    当前知识库暂无材料
    +
    +
    + +
    +
    +

    内置法规材料

    + +
    +
    + + + + + + + + + + + + + {% for source in knowledge_base.sources %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    状态文件类型大小索引操作
    {% if source.supported %}可解析{% else %}暂不支持{% endif %}{{ source.relative_path }}{{ source.suffix }}{{ source.size }} bytes{{ source.indexed_label }} + +
    暂无法规材料
    +
    +
    +
    +
    +
    +
    +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/registration/login.html b/templates/registration/login.html new file mode 100644 index 0000000..9abb775 --- /dev/null +++ b/templates/registration/login.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} + +{% block title %}登录 - DEMO-AGENT V2{% endblock %} + +{% block content %} +
    + +
    +{% endblock %} diff --git a/templates/registration/password_change.html b/templates/registration/password_change.html new file mode 100644 index 0000000..50bebda --- /dev/null +++ b/templates/registration/password_change.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{% block title %}修改密码 - DEMO-AGENT V2{% endblock %} + +{% block content %} +
    + +
    +{% endblock %} diff --git a/templates/workbench.html b/templates/workbench.html new file mode 100644 index 0000000..e8a323e --- /dev/null +++ b/templates/workbench.html @@ -0,0 +1,173 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}首页 - DEMO-AGENT V2{% endblock %} +{% block body_class %}app-body{% endblock %} + +{% block content %} +
    +
    + + +
    +
    + +
    +
    +
    + +
    +
    +
    +

    首页

    +

    注册资料审核工作台

    +

    当前账号资料、知识库、附件与审核处理数据总览。

    +
    + 进入审核智能体 +
    + +
    +
    + 对话总数 + {{ dashboard.metrics.conversation_count }} + 已处理 {{ dashboard.metrics.recent_conversation_count }} +
    +
    + 附件总数 + {{ dashboard.metrics.attachment_count }} + 启用 {{ dashboard.metrics.active_attachment_count }} +
    +
    + 知识库材料 + {{ dashboard.metrics.knowledge_document_count }} + 管理 {{ dashboard.knowledge.document_count }} · 内置 {{ dashboard.knowledge.builtin_source_count }} +
    +
    + 执行中批次 + {{ dashboard.metrics.running_batch_count }} + 总批次 {{ dashboard.metrics.total_batch_count }} +
    +
    + 已处理批次 + {{ dashboard.metrics.handled_batch_count }} + 成功 {{ dashboard.metrics.success_batch_count }} +
    +
    + 等待确认 + {{ dashboard.metrics.waiting_batch_count }} + 需人工处理 +
    +
    + 失败批次 + {{ dashboard.metrics.failed_batch_count }} + 需排查 +
    +
    + 申报填表 + {{ dashboard.workflow.application_form_fill_count }} + 自动填表批次 +
    +
    + +
    +
    +
    +

    知识库概览

    +
    +
    +
    +
    管理文档
    +
    {{ dashboard.knowledge.document_count }}
    +
    +
    +
    内置材料
    +
    {{ dashboard.knowledge.builtin_source_count }}
    +
    +
    +
    已索引
    +
    {{ dashboard.knowledge.indexed_document_count }}
    +
    +
    +
    向量片段
    +
    {{ dashboard.knowledge.chunk_count }}
    +
    +
    +
    + +
    +
    +

    附件与文档概览

    +
    +
    +
    +
    附件总数
    +
    {{ dashboard.attachments.attachment_count }}
    +
    +
    +
    启用附件
    +
    {{ dashboard.attachments.active_attachment_count }}
    +
    +
    +
    最近上传
    +
    {{ dashboard.attachments.recent_attachment_count }}
    +
    +
    +
    关联对话
    +
    {{ dashboard.attachments.conversation_count }}
    +
    +
    +
    +
    + +
    +
    +

    最近处理记录

    + 最近 8 条 +
    +
    + + + + + + + + + + + + {% for record in dashboard.recent_records %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    类型名称或批次号状态更新时间入口
    {{ record.type }}{{ record.title }}{{ record.status }}{{ record.updated_at|date:"Y-m-d H:i" }} + 查看 +
    暂无处理记录
    +
    +
    +
    +
    +{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py index 85c9ccf..9912414 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,13 +2,7 @@ import pytest @pytest.fixture(autouse=True) -def force_mock_llm_provider_for_tests(monkeypatch): - """ - 测试环境固定使用 mock Provider。 +def mock_regulatory_info_package_page_count(monkeypatch): + from review_agent.regulatory_info_package.services import package_generate - 当前项目会从根目录 `.env` 自动读取真实模型配置,这对本地运行很有帮助, - 但单元测试和页面回归测试不应该依赖外部网络或真实密钥状态。 - 因此这里统一覆盖为 mock,保证测试稳定、可重复。 - """ - monkeypatch.setenv("LLM_PROVIDER", "mock") - monkeypatch.setenv("LLM_MODEL", "mock-model") + monkeypatch.setattr(package_generate, "count_document_pages", lambda _path: 1) diff --git a/tests/fixtures/regulatory/attachment4_outline.json b/tests/fixtures/regulatory/attachment4_outline.json new file mode 100644 index 0000000..25d8d98 --- /dev/null +++ b/tests/fixtures/regulatory/attachment4_outline.json @@ -0,0 +1,8 @@ +[ + {"code": "1", "title": "监管信息", "children": ["章节目录", "申请表", "术语/缩写词列表", "产品列表", "关联文件", "申报前与监管机构的联系情况和沟通记录", "符合性声明"]}, + {"code": "2", "title": "综述资料", "children": ["章节目录", "概述", "产品描述", "预期用途", "申报产品上市历史", "其他需说明的内容"]}, + {"code": "3", "title": "非临床资料", "children": ["章节目录", "产品风险管理资料", "体外诊断试剂安全和性能基本原则清单", "产品技术要求及检验报告", "分析性能研究", "稳定性研究", "阳性判断值或参考区间研究", "其他资料"]}, + {"code": "4", "title": "临床评价资料", "children": ["章节目录", "临床评价资料"]}, + {"code": "5", "title": "产品说明书和标签样稿", "children": ["章节目录", "产品说明书", "标签样稿", "其他资料"]}, + {"code": "6", "title": "质量管理体系文件", "children": ["综述", "章节目录", "生产制造信息", "质量管理体系程序", "管理职责程序", "资源管理程序", "产品实现程序", "质量管理体系的测量/分析和改进程序", "其他质量体系程序信息", "质量管理体系核查文件"]} +] diff --git a/tests/test_application_form_fill_field_extract.py b/tests/test_application_form_fill_field_extract.py new file mode 100644 index 0000000..4b1494b --- /dev/null +++ b/tests/test_application_form_fill_field_extract.py @@ -0,0 +1,217 @@ +import json + +import pytest + +from review_agent.application_form_fill.services.field_extract import ( + extract_by_llm, + extract_by_rules, + run_parallel_extract, + save_field_extract_result, +) +from review_agent.application_form_fill.services.template_config import load_template_config +from review_agent.application_form_fill.services.template_select import select_templates +from review_agent.models import ( + ApplicationFormFillArtifact, + ApplicationFormFillBatch, + Conversation, + FileSummaryBatch, +) + + +pytestmark = pytest.mark.django_db + + +def _registration_specs(): + config = load_template_config() + specs, _risk_notes = select_templates(config, ["registration_certificate"], "首次注册") + return specs + + +def test_rule_extracts_registration_certificate_fields(): + texts = { + "产品说明书.txt": "\n".join( + [ + "产品名称:甲胎蛋白检测试剂盒", + "包装规格:20人份/盒", + "预期用途:用于体外定量检测人血清中甲胎蛋白含量", + "产品储存条件及有效期:2-8℃保存,有效期12个月", + ] + ) + } + + result = extract_by_rules(texts, _registration_specs()) + + values = {field["key"]: field for field in result["fields"]} + assert values["product_name"]["value"] == "甲胎蛋白检测试剂盒" + assert values["intended_use"]["source_role"] == "说明书" + assert "2-8℃保存" in values["storage_condition_and_validity"]["value"] + assert values["package_specification"]["extractor"] == "rule" + + +def test_rule_extracts_bracket_sections_from_instructions(): + texts = { + "目标产品说明书.docx": "\n".join( + [ + "【产品名称】", + "新型冠状病毒2019-nCoV核酸检测试剂盒(荧光PCR法)", + "【包装规格】", + "规格A:24人份/盒、48人份/盒、96人份/盒。", + "规格B:24人份/盒、48人份/盒、96人份/盒。", + "【预期用途】", + "本试剂盒用于体外定性检测咽拭子、痰液样本中新型冠状病毒(2019-nCoV)ORF1ab和N基因。", + "【检测原理】", + "本段不应进入预期用途。", + "【主要组成成分】", + "表1 规格A大包装试剂盒组成成分", + "组分\t规格\t数量", + "PCR反应液\t24人份/盒\t1管", + "【储存条件及有效期】", + "-20±5℃的避光条件,有效期12个月。", + "反复冻融次数不得超过4次。", + "【样本要求】", + "适用样本类型:咽拭子、痰液。", + ] + ) + } + + result = extract_by_rules(texts, _registration_specs()) + + values = {field["key"]: field["value"] for field in result["fields"]} + assert values["product_name"] == "新型冠状病毒2019-nCoV核酸检测试剂盒(荧光PCR法)" + assert "规格A" in values["package_specification"] + assert "检测原理" not in values["intended_use"] + assert "PCR反应液" in values["main_components"] + assert "-20±5℃" in values["storage_condition_and_validity"] + + +def test_rule_maps_agent_fields_to_manufacturer_company_for_now(): + texts = { + "目标产品说明书.docx": "\n".join( + [ + "生产企业名称:卡尤迪生物科技宜兴有限公司", + "生产企业住所:江苏省宜兴经济技术开发区杏里路10号", + "生产地址:江苏省宜兴经济技术开发区杏里路10号宜兴光电产业园4幢102室", + ] + ) + } + + result = extract_by_rules(texts, _registration_specs()) + + values = {field["key"]: field["value"] for field in result["fields"]} + assert values["agent_name"] == "卡尤迪生物科技宜兴有限公司" + assert values["agent_address"] == "江苏省宜兴经济技术开发区杏里路10号" + assert values["manufacturer_address"] == "江苏省宜兴经济技术开发区杏里路10号宜兴光电产业园4幢102室" + + +def test_rule_stops_product_name_before_application_form_instructions(): + texts = { + "境内体外诊断试剂注册申请表.docx": "\n".join( + [ + "产品名称:呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)", + "申请人:", + "卡尤迪生物科技宜兴有限公司", + "国家药品监督管理局", + "填表说明", + "1. 本表依据《体外诊断注册与备案管理办法》制定。", + ] + ) + } + + result = extract_by_rules(texts, _registration_specs()) + + values = {field["key"]: field["value"] for field in result["fields"]} + assert values["product_name"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)" + assert "填表说明" not in values["product_name"] + + +def test_rule_ignores_generic_enterprise_name_from_application_form(): + texts = { + "CH1.4 申请表.docx": "\n".join( + [ + "注册人制度\t是 企业名称:否", + "优先通道申请 应急通道 同品种首个产品首次申报", + "临床试验", + "临床试验机构名称: 中国医学科学院北京协和医院、晋中市第一人民医院", + "应附资料", + ] + ) + } + + result = extract_by_rules(texts, _registration_specs()) + + values = {field["key"]: field["value"] for field in result["fields"]} + assert "applicant_name" not in values + assert "agent_name" not in values + + +def test_llm_extract_parses_structured_json(monkeypatch): + monkeypatch.setattr( + "review_agent.application_form_fill.services.field_extract.generate_completion", + lambda messages, temperature=0.0: json.dumps( + { + "fields": [ + { + "key": "product_name", + "label": "产品名称", + "value": "甲胎蛋白检测试剂盒", + "source_file": "说明书.txt", + "source_role": "说明书", + "evidence": "产品名称:甲胎蛋白检测试剂盒", + "confidence": 0.9, + } + ], + "checklist_items": [], + }, + ensure_ascii=False, + ), + ) + + result = extract_by_llm({"说明书.txt": "产品名称:甲胎蛋白检测试剂盒"}, _registration_specs()) + + assert result["fields"][0]["extractor"] == "llm" + assert result["fields"][0]["value"] == "甲胎蛋白检测试剂盒" + + +def test_llm_extract_failure_returns_empty_result(monkeypatch): + monkeypatch.setattr( + "review_agent.application_form_fill.services.field_extract.generate_completion", + lambda messages, temperature=0.0: (_ for _ in ()).throw(TimeoutError("timeout")), + ) + + result = extract_by_llm({"说明书.txt": "产品名称:甲胎蛋白检测试剂盒"}, _registration_specs()) + + assert result["fields"] == [] + assert "timeout" in result["error_message"] + + +def test_parallel_extract_preserves_rule_result_when_llm_fails(monkeypatch): + monkeypatch.setattr( + "review_agent.application_form_fill.services.field_extract.generate_completion", + lambda messages, temperature=0.0: (_ for _ in ()).throw(TimeoutError("timeout")), + ) + + payload = run_parallel_extract({"说明书.txt": "产品名称:甲胎蛋白检测试剂盒"}, _registration_specs()) + + assert payload["regex_results"]["fields"] + assert payload["llm_results"]["fields"] == [] + assert payload["selected_templates"] == ["registration_certificate"] + + +def test_save_field_extract_result_creates_json_artifact(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-FIELD") + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="AFF-FIELD", + work_dir=str(tmp_path / "aff" / "AFF-FIELD"), + ) + + artifact = save_field_extract_result(batch, {"regex_results": {"fields": []}, "llm_results": {"fields": []}}) + + assert artifact.artifact_type == ApplicationFormFillArtifact.ArtifactType.FIELD_EXTRACT_RESULT + assert artifact.file_format == ApplicationFormFillArtifact.FileFormat.JSON + assert artifact.content_hash diff --git a/tests/test_application_form_fill_field_merge.py b/tests/test_application_form_fill_field_merge.py new file mode 100644 index 0000000..261f612 --- /dev/null +++ b/tests/test_application_form_fill_field_merge.py @@ -0,0 +1,111 @@ +import pytest + +from review_agent.application_form_fill.services.field_merge import merge_fields, normalize_field_value, rank_source + + +def test_normalize_field_value_removes_whitespace(): + assert normalize_field_value(" 2-8℃ 保存 \n 有效期12个月 ") == "2-8℃保存有效期12个月" + + +def test_rank_source_prefers_instructions(): + assert rank_source("说明书") < rank_source("产品技术要求") + + +def test_merge_fields_prefers_instructions_and_marks_conflict(): + regex_results = { + "fields": [ + { + "key": "storage_condition_and_validity", + "label": "产品储存条件及有效期", + "value": "2-8℃保存,有效期12个月", + "source_file": "说明书.txt", + "source_role": "说明书", + "evidence": "产品储存条件及有效期:2-8℃保存,有效期12个月", + "confidence": 0.75, + }, + { + "key": "storage_condition_and_validity", + "label": "产品储存条件及有效期", + "value": "-20℃保存", + "source_file": "产品技术要求.txt", + "source_role": "产品技术要求", + "evidence": "产品储存条件及有效期:-20℃保存", + "confidence": 0.8, + }, + ] + } + + merged, conflicts = merge_fields(regex_results, {"fields": []}) + + field = merged["storage_condition_and_validity"] + assert field.value == "2-8℃保存,有效期12个月" + assert field.has_conflict is True + assert conflicts[0]["selected_value"] == "2-8℃保存,有效期12个月" + assert conflicts[0]["conflict_values"][0]["value"] == "-20℃保存" + + +def test_merge_fields_combines_consistent_values_without_conflict(): + regex_results = { + "fields": [ + { + "key": "product_name", + "label": "产品名称", + "value": "甲胎蛋白检测试剂盒", + "source_file": "说明书.txt", + "source_role": "说明书", + "evidence": "产品名称:甲胎蛋白检测试剂盒", + "confidence": 0.75, + } + ] + } + llm_results = { + "fields": [ + { + "key": "product_name", + "label": "产品名称", + "value": "甲胎蛋白 检测试剂盒", + "source_file": "产品技术要求.txt", + "source_role": "产品技术要求", + "evidence": "产品名称:甲胎蛋白 检测试剂盒", + "confidence": 0.9, + } + ] + } + + merged, conflicts = merge_fields(regex_results, llm_results) + + assert merged["product_name"].value == "甲胎蛋白检测试剂盒" + assert merged["product_name"].has_conflict is False + assert conflicts == [] + + +def test_merge_fields_fills_agent_from_applicant_for_now(): + regex_results = { + "fields": [ + { + "key": "applicant_name", + "label": "注册人名称", + "value": "卡尤迪生物科技宜兴有限公司", + "source_file": "目标产品说明书.docx", + "source_role": "说明书", + "evidence": "生产企业名称:卡尤迪生物科技宜兴有限公司", + "confidence": 0.75, + }, + { + "key": "applicant_address", + "label": "注册人住所", + "value": "江苏省宜兴经济技术开发区杏里路10号", + "source_file": "目标产品说明书.docx", + "source_role": "说明书", + "evidence": "生产企业住所:江苏省宜兴经济技术开发区杏里路10号", + "confidence": 0.75, + }, + ] + } + + merged, conflicts = merge_fields(regex_results, {"fields": []}) + + assert merged["agent_name"].value == "卡尤迪生物科技宜兴有限公司" + assert merged["agent_name"].label == "代理人名称" + assert merged["agent_address"].value == "江苏省宜兴经济技术开发区杏里路10号" + assert conflicts == [] diff --git a/tests/test_application_form_fill_frontend.py b/tests/test_application_form_fill_frontend.py new file mode 100644 index 0000000..55563f7 --- /dev/null +++ b/tests/test_application_form_fill_frontend.py @@ -0,0 +1,77 @@ +import pytest +from django.urls import reverse + +from review_agent.models import ApplicationFormFillBatch, Conversation, FileSummaryBatch, WorkflowNodeRun + + +pytestmark = pytest.mark.django_db + + +def test_workspace_renders_application_form_fill_workflow_card(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-CARD") + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="AFF-CARD", + status=ApplicationFormFillBatch.Status.PARTIAL_SUCCESS, + selected_templates=["registration_certificate"], + risk_notes=[{"type": "pdf_pending"}], + ) + WorkflowNodeRun.objects.create( + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + node_group="form_fill", + node_code="word_fill", + node_name="填写 Word", + status=WorkflowNodeRun.Status.SUCCESS, + progress=100, + ) + client.force_login(user) + + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") + + content = response.content.decode("utf-8") + assert "AFF-CARD" in content + assert 'data-workflow-type="application_form_fill"' in content + assert "填写 Word" in content + assert "data-application-form-fill-status-url-template" in content + + +def test_frontend_selects_application_form_fill_status_url_and_terminal_status(): + script = open("static/js/app.js", encoding="utf-8").read() + + assert 'workflow_type === "application_form_fill"' in script + assert "data-application-form-fill-status-url-template" in script + assert 'status === "partial_success"' in script + + +def test_application_form_fill_status_includes_no_feishu_notification(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-AFF") + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="AFF-FEISHU", + ) + client.force_login(user) + + response = client.get(f"/api/review-agent/application-form-fill/{batch.pk}/status/") + + payload = response.json() + assert payload["latest_notification"] is None + assert payload["notifications"] == [] + + +def test_frontend_renders_feishu_notification_status(): + script = open("static/js/app.js", encoding="utf-8").read() + css = open("static/css/login.css", encoding="utf-8").read() + + assert "renderNotificationSummary" in script + assert "暂无飞书通知记录" in script + assert "workflow-notification" in script + assert ".workflow-notification" in css diff --git a/tests/test_application_form_fill_models.py b/tests/test_application_form_fill_models.py new file mode 100644 index 0000000..92be9df --- /dev/null +++ b/tests/test_application_form_fill_models.py @@ -0,0 +1,109 @@ +import pytest + +from review_agent.models import ( + ApplicationFormFillArtifact, + ApplicationFormFillBatch, + ApplicationFormFillNotificationRecord, + Conversation, + ExportedSummaryFile, + FileSummaryBatch, + Message, + RegulatoryReviewBatch, +) + + +pytestmark = pytest.mark.django_db + + +def test_application_form_fill_models_store_batch_artifact_notification_and_exports(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="自动填表") + trigger = Message.objects.create( + conversation=conversation, + role=Message.Role.USER, + content="帮我填注册证", + ) + summary_batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-AFF-READY", + status=FileSummaryBatch.Status.SUCCESS, + ) + regulatory_batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary_batch, + batch_no="RR-AFF-SOURCE", + condition_json={"confirmed": True, "registration_type": "首次注册"}, + ) + + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + trigger_message=trigger, + source_summary_batch=summary_batch, + source_regulatory_batch=regulatory_batch, + batch_no="AFF-20260607153000-abcdef", + requested_templates=["registration_certificate"], + selected_templates=["registration_certificate"], + output_types=["word", "excel", "json"], + registration_type="首次注册", + registration_type_source=ApplicationFormFillBatch.RegistrationTypeSource.USER_MESSAGE, + product_name="甲胎蛋白检测试剂盒", + conflict_summary=[{"field_key": "storage_condition"}], + risk_notes=[{"type": "pdf_pending"}], + template_config_version="application_form_templates_v1", + template_config_hash="hash", + work_dir="media/application_form_fill/1/1/AFF-20260607153000-abcdef", + ) + artifact = ApplicationFormFillArtifact.objects.create( + batch=batch, + artifact_type=ApplicationFormFillArtifact.ArtifactType.FILLED_TEMPLATE, + file_format=ApplicationFormFillArtifact.FileFormat.DOCX, + name="注册证格式", + file_name="filled.docx", + storage_path="media/application_form_fill/filled.docx", + file_size=123, + content_hash="sha256", + metadata={"template_code": "registration_certificate"}, + created_by_node="word_fill", + ) + notification = ApplicationFormFillNotificationRecord.objects.create( + batch=batch, + recipient=user, + channel=ApplicationFormFillNotificationRecord.Channel.MOCK, + template_codes=["registration_certificate"], + export_ids=[1], + message_summary="自动填表完成", + send_status=ApplicationFormFillNotificationRecord.SendStatus.FAILED, + retry_count=1, + error_message="mock failed", + ) + word_export = ExportedSummaryFile.objects.create( + batch=summary_batch, + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + export_category="filled_template", + export_type=ExportedSummaryFile.ExportType.WORD, + file_name="filled.docx", + storage_path="media/application_form_fill/filled.docx", + ) + pdf_export = ExportedSummaryFile.objects.create( + batch=summary_batch, + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + export_category="filled_template", + export_type=ExportedSummaryFile.ExportType.PDF, + file_name="filled.pdf", + storage_path="media/application_form_fill/filled.pdf", + ) + + assert batch.status == ApplicationFormFillBatch.Status.PENDING + assert batch.source_summary_batch == summary_batch + assert batch.source_regulatory_batch == regulatory_batch + assert artifact.content_hash == "sha256" + assert artifact.metadata["template_code"] == "registration_certificate" + assert notification.send_status == ApplicationFormFillNotificationRecord.SendStatus.FAILED + assert notification.retry_count == 1 + assert word_export.export_type == ExportedSummaryFile.ExportType.WORD + assert pdf_export.export_type == ExportedSummaryFile.ExportType.PDF diff --git a/tests/test_application_form_fill_notification.py b/tests/test_application_form_fill_notification.py new file mode 100644 index 0000000..86fbeae --- /dev/null +++ b/tests/test_application_form_fill_notification.py @@ -0,0 +1,72 @@ +import pytest + +from review_agent.application_form_fill.services.notifier import notify_completion +from review_agent.models import ( + ApplicationFormFillBatch, + ApplicationFormFillNotificationRecord, + Conversation, + ExportedSummaryFile, + FileSummaryBatch, +) + + +pytestmark = pytest.mark.django_db + + +def test_notify_completion_records_success(django_user_model, monkeypatch): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-NOTIFY") + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="AFF-NOTIFY", + selected_templates=["registration_certificate"], + ) + exported = ExportedSummaryFile.objects.create( + batch=summary, + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + export_category="filled_template", + export_type=ExportedSummaryFile.ExportType.WORD, + file_name="filled.docx", + storage_path="filled.docx", + ) + calls = [] + fake_record = type( + "Record", + (), + {"send_status": "success", "SendStatus": type("SendStatus", (), {"FAILED": "failed"}), "error_message": ""}, + )() + monkeypatch.setattr( + "review_agent.application_form_fill.services.notifier.dispatch_workflow_notification", + lambda context: calls.append(context) or fake_record, + ) + + record = notify_completion(batch, [exported]) + + assert record.send_status == ApplicationFormFillNotificationRecord.SendStatus.SUCCESS + assert record.export_ids == [exported.pk] + assert record.template_codes == ["registration_certificate"] + assert record.sent_at is not None + assert calls[0].workflow_type == "application_form_fill" + + +def test_notify_completion_records_failure_without_raising(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-NOTIFY-FAIL") + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="AFF-NOTIFY-FAIL", + selected_templates=["registration_certificate"], + ) + + record = notify_completion(batch, [], fail=True) + + assert record.send_status == ApplicationFormFillNotificationRecord.SendStatus.FAILED + assert record.retry_count == 1 + assert "mock" in record.error_message diff --git a/tests/test_application_form_fill_summary.py b/tests/test_application_form_fill_summary.py new file mode 100644 index 0000000..b9d66d2 --- /dev/null +++ b/tests/test_application_form_fill_summary.py @@ -0,0 +1,39 @@ +import pytest + +from review_agent.application_form_fill.services.summary import build_assistant_summary +from review_agent.models import ApplicationFormFillBatch, Conversation, FileSummaryBatch + + +pytestmark = pytest.mark.django_db + + +def test_assistant_summary_compacts_long_conflict_values(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-SUMMARY") + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="AFF-SUMMARY", + conflict_summary=[ + { + "field_key": "applicant_name", + "field_label": "注册人名称", + "selected_value": "卡尤迪生物科技宜兴有限公司", + "conflict_values": [ + { + "source_file": "CH1.4 申请表.docx", + "value": "否\n临床试验\n临床试验机构名称: 中国医学科学院北京协和医院、晋中市第一人民医院、北京市疾病预防控制中心 临床数据库.zip\n应附资料", + } + ], + "handling": "说明书优先,模板内黄底红字高亮", + } + ], + ) + + content = build_assistant_summary(batch, []) + + assert "临床试验机构名称" in content + assert len([line for line in content.splitlines() if "临床试验机构名称" in line][0]) < 220 + assert "\n临床试验\n" not in content diff --git a/tests/test_application_form_fill_template_config.py b/tests/test_application_form_fill_template_config.py new file mode 100644 index 0000000..b8e8859 --- /dev/null +++ b/tests/test_application_form_fill_template_config.py @@ -0,0 +1,97 @@ +import copy + +import pytest + +from review_agent.application_form_fill.services.template_config import ( + DEFAULT_CONFIG_PATH, + compute_config_hash, + load_template_config, + validate_template_config, +) + + +def test_template_config_loads_and_validates_default_yaml(settings): + config = load_template_config() + errors = validate_template_config(config) + + assert errors == [] + assert config["version"] == "application_form_templates_v1" + registration = next(item for item in config["templates"] if item["code"] == "registration_certificate") + assert registration["file_format"] == "docx" + assert {field["key"] for field in registration["fields"]} >= { + "applicant_name", + "product_name", + "package_specification", + "main_components", + "intended_use", + "storage_condition_and_validity", + "attachments", + } + assert all(field["target"]["type"] == "table_row" for field in registration["fields"]) + assert len(compute_config_hash(DEFAULT_CONFIG_PATH)) == 64 + + +def test_template_config_reports_missing_source_dir(): + config = load_template_config() + config["source_dir"] = "docs/not-exists" + + errors = validate_template_config(config) + + assert any("source_dir 不存在" in error for error in errors) + + +def test_template_config_reports_duplicate_code(): + config = load_template_config() + duplicate = copy.deepcopy(config["templates"][0]) + config["templates"].append(duplicate) + + errors = validate_template_config(config) + + assert any("模板 code 重复" in error for error in errors) + + +def test_template_config_reports_missing_source_file(): + config = load_template_config() + config["templates"][0]["source_file"] = "missing.docx" + + errors = validate_template_config(config) + + assert any("source_file 不存在" in error for error in errors) + + +def test_template_config_reports_unsupported_target_type(): + config = load_template_config() + config["templates"][0]["fields"][0]["target"]["type"] = "content_control" + + errors = validate_template_config(config) + + assert any("target.type 不支持" in error for error in errors) + + +def test_template_config_loads_custom_path(tmp_path): + config_path = tmp_path / "templates.yaml" + config_path.write_text( + """ +version: custom +source_dir: . +templates: + - code: custom_template + name: Custom + source_file: source.docx + output_label: Custom + file_format: docx + fields: + - key: product_name + label: 产品名称 + target: + type: table_row + row_label: 产品名称 +""".strip(), + encoding="utf-8", + ) + (tmp_path / "source.docx").write_bytes(b"docx") + + config = load_template_config(config_path) + + assert validate_template_config(config, base_dir=tmp_path) == [] + assert compute_config_hash(config_path) diff --git a/tests/test_application_form_fill_template_repository.py b/tests/test_application_form_fill_template_repository.py new file mode 100644 index 0000000..aafa001 --- /dev/null +++ b/tests/test_application_form_fill_template_repository.py @@ -0,0 +1,60 @@ +import pytest + +from review_agent.application_form_fill.services.template_config import load_template_config +from review_agent.application_form_fill.services.template_repository import ( + TemplateUnavailableError, + copy_template_to_batch, + resolve_source_template, +) +from review_agent.application_form_fill.services.template_select import select_templates +from review_agent.models import ( + ApplicationFormFillArtifact, + ApplicationFormFillBatch, + Conversation, + FileSummaryBatch, +) + + +pytestmark = pytest.mark.django_db + + +def test_resolve_source_template_finds_registration_docx(): + config = load_template_config() + specs, _risk_notes = select_templates(config, ["registration_certificate"], "首次注册") + + path = resolve_source_template(specs[0], config) + + assert path.exists() + assert path.name == "中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx" + + +def test_copy_template_to_batch_creates_artifact(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-REPO") + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="AFF-REPO", + work_dir=str(tmp_path / "aff" / "AFF-REPO"), + ) + config = load_template_config() + specs, _risk_notes = select_templates(config, ["registration_certificate"], "首次注册") + + artifact = copy_template_to_batch(specs[0], batch, config) + + assert artifact.artifact_type == ApplicationFormFillArtifact.ArtifactType.TEMPLATE_COPY + assert artifact.file_format == "docx" + assert artifact.content_hash + assert artifact.metadata["template_code"] == "registration_certificate" + assert artifact.storage_path.startswith(batch.work_dir) + + +def test_doc_template_without_working_docx_is_unavailable(): + config = load_template_config() + specs, _risk_notes = select_templates(config, ["change_registration"], "变更注册") + + with pytest.raises(TemplateUnavailableError): + resolve_source_template(specs[0], config) diff --git a/tests/test_application_form_fill_template_select.py b/tests/test_application_form_fill_template_select.py new file mode 100644 index 0000000..dada57e --- /dev/null +++ b/tests/test_application_form_fill_template_select.py @@ -0,0 +1,114 @@ +import pytest + +from review_agent.application_form_fill.services.template_config import load_template_config +from review_agent.application_form_fill.services.template_select import ( + detect_registration_type, + parse_requested_templates, + select_templates, +) +from review_agent.models import ApplicationFormFillBatch, Conversation, FileSummaryBatch, RegulatoryReviewBatch + + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize( + ("message", "expected"), + [ + ("帮我填注册证", ["registration_certificate"]), + ("生成变更注册备案文件", ["change_registration"]), + ("生成安全和性能基本原则清单", ["essential_principles"]), + ("请生成全部模板", ["registration_certificate", "change_registration", "essential_principles"]), + ("普通聊天", []), + ], +) +def test_parse_requested_templates(message, expected): + assert parse_requested_templates(message) == expected + + +def test_detect_registration_type_prefers_user_message(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-SEL") + regulatory = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-SEL", + condition_json={"confirmed_conditions": {"registration_type": "变更注册"}}, + ) + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + source_regulatory_batch=regulatory, + batch_no="AFF-SEL", + ) + + value, source = detect_registration_type(batch=batch, message="首次注册资料,请填注册证") + + assert value == "首次注册" + assert source == ApplicationFormFillBatch.RegistrationTypeSource.USER_MESSAGE + + +def test_detect_registration_type_falls_back_to_regulatory_batch_and_file_candidates(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-SEL-2") + regulatory = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-SEL-2", + condition_json={"confirmed_conditions": {"registration_type": "变更注册"}}, + ) + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + source_regulatory_batch=regulatory, + batch_no="AFF-SEL-2", + ) + + regulatory_value, regulatory_source = detect_registration_type(batch=batch, message="") + file_value, file_source = detect_registration_type( + message="", + file_candidates={"registration_type": {"suggested": "备案"}}, + ) + + assert (regulatory_value, regulatory_source) == ( + "变更注册", + ApplicationFormFillBatch.RegistrationTypeSource.REGULATORY_BATCH, + ) + assert (file_value, file_source) == ( + "备案", + ApplicationFormFillBatch.RegistrationTypeSource.FILE_EXTRACT, + ) + + +def test_select_default_templates_for_initial_registration(): + config = load_template_config() + + specs, risk_notes = select_templates(config, [], "首次注册") + + assert [spec.code for spec in specs] == ["registration_certificate", "essential_principles"] + assert risk_notes == [] + + +def test_select_default_templates_for_change_registration(): + config = load_template_config() + + specs, risk_notes = select_templates(config, [], "变更注册") + + assert [spec.code for spec in specs] == ["change_registration", "essential_principles"] + assert risk_notes == [] + + +def test_select_user_requested_mismatch_is_allowed_with_risk_note(): + config = load_template_config() + + specs, risk_notes = select_templates(config, ["change_registration"], "首次注册") + + assert [spec.code for spec in specs] == ["change_registration"] + assert risk_notes + assert risk_notes[0]["type"] == "template_registration_mismatch" diff --git a/tests/test_application_form_fill_traceability.py b/tests/test_application_form_fill_traceability.py new file mode 100644 index 0000000..cec08f8 --- /dev/null +++ b/tests/test_application_form_fill_traceability.py @@ -0,0 +1,85 @@ +import json + +import pytest +from openpyxl import load_workbook + +from review_agent.application_form_fill.schemas import MergedField, TemplateSpec +from review_agent.application_form_fill.services.traceability_export import save_traceability_exports +from review_agent.models import ( + ApplicationFormFillArtifact, + ApplicationFormFillBatch, + Conversation, + ExportedSummaryFile, + FileSummaryBatch, +) + + +pytestmark = pytest.mark.django_db + + +def test_traceability_exports_excel_json_and_records(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-TRACE") + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="AFF-TRACE", + work_dir=str(tmp_path / "aff" / "AFF-TRACE"), + ) + spec = TemplateSpec( + code="registration_certificate", + name="注册证格式", + source_file="template.docx", + output_label="注册证格式", + applies_when={}, + file_format="docx", + fields=[{"key": "product_name", "label": "产品名称"}], + ) + merged_fields = { + "product_name": MergedField( + "product_name", + "产品名称", + "甲胎蛋白检测试剂盒", + "说明书.txt", + "产品名称:甲胎蛋白检测试剂盒", + 0.8, + ) + } + conflicts = [ + { + "field_key": "storage_condition", + "field_label": "储存条件", + "selected_value": "2-8℃", + "conflict_values": [{"value": "-20℃", "source_file": "产品技术要求.txt"}], + "handling": "说明书优先", + } + ] + + exports = save_traceability_exports( + batch, + merged_fields, + conflicts, + [spec], + [{"template_label": "注册证格式", "word_status": "success", "pdf_status": "待增强"}], + ) + + assert {export.export_type for export in exports} == { + ExportedSummaryFile.ExportType.EXCEL, + ExportedSummaryFile.ExportType.JSON, + } + excel_export = next(export for export in exports if export.export_type == ExportedSummaryFile.ExportType.EXCEL) + workbook = load_workbook(excel_export.storage_path) + assert workbook.sheetnames == ["字段追溯", "冲突字段", "低置信度条目", "生成结果"] + assert workbook["字段追溯"]["B2"].value == "产品名称" + assert workbook["冲突字段"]["C2"].value == "-20℃" + + json_export = next(export for export in exports if export.export_type == ExportedSummaryFile.ExportType.JSON) + payload = json.loads(open(json_export.storage_path, encoding="utf-8").read()) + assert payload["merged_fields"]["product_name"]["value"] == "甲胎蛋白检测试剂盒" + assert ApplicationFormFillArtifact.objects.filter( + batch=batch, + artifact_type=ApplicationFormFillArtifact.ArtifactType.TRACEABILITY, + ).exists() diff --git a/tests/test_application_form_fill_trigger.py b/tests/test_application_form_fill_trigger.py new file mode 100644 index 0000000..5235c8a --- /dev/null +++ b/tests/test_application_form_fill_trigger.py @@ -0,0 +1,73 @@ +import pytest + +from review_agent.models import Conversation, FileAttachment +from review_agent.skill_router import route_message_intent + + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize( + "content", + [ + "帮我填注册证", + "给我这个内容对应的表格", + "为我该方案生成申报模板", + "请自动填表并生成表格", + "生成安全和性能基本原则清单", + ], +) +def test_rule_router_starts_application_form_fill_for_keywords(monkeypatch, django_user_model, content): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + monkeypatch.setattr( + "review_agent.skill_router._route_with_llm", + lambda conversation, content, attachments: (_ for _ in ()).throw(ValueError("fallback")), + ) + + route = route_message_intent(conversation, content) + + assert route.action == "application_form_fill" + assert route.workflow_type == "application_form_fill" + assert route.starts_application_form_fill + + +def test_rule_router_does_not_misroute_normal_chat(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + monkeypatch.setattr( + "review_agent.skill_router._route_with_llm", + lambda conversation, content, attachments: (_ for _ in ()).throw(ValueError("fallback")), + ) + + route = route_message_intent(conversation, "你好,解释一下法规背景") + + assert route.action == "normal_chat" + + +def test_application_form_fill_prompt_preempts_attachment_reader_llm(monkeypatch, tmp_path, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + archive_path = tmp_path / "第1章_监管信息.rar" + archive_path.write_bytes(b"rar") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="第1章_监管信息.rar", + storage_path=str(archive_path), + file_size=archive_path.stat().st_size, + ) + monkeypatch.setattr( + "review_agent.skill_router._route_with_llm", + lambda conversation, content, attachments: (_ for _ in ()).throw( + AssertionError("明确自动填表意图不应进入 LLM 路由") + ), + ) + + route = route_message_intent( + conversation, + "请基于当前对话最近成功汇总的产品资料,自动提取产品关键信息并填入申报文件模板,优先生成注册证 Word 和字段来源追溯清单。", + ) + + assert route.action == "application_form_fill" + assert route.source == "rule_preflight" diff --git a/tests/test_application_form_fill_views.py b/tests/test_application_form_fill_views.py new file mode 100644 index 0000000..65c6b77 --- /dev/null +++ b/tests/test_application_form_fill_views.py @@ -0,0 +1,113 @@ +import json + +import pytest +from django.urls import reverse + +from review_agent.application_form_fill.constants import FORM_FILL_NODE_DEFINITIONS +from review_agent.models import ( + ApplicationFormFillBatch, + Conversation, + ExportedSummaryFile, + FileSummaryBatch, + WorkflowNodeRun, +) + + +pytestmark = pytest.mark.django_db + + +def test_application_form_fill_start_requires_conversation_owner(client, monkeypatch, django_user_model): + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + conversation = Conversation.objects.create(user=owner, title="会话") + FileSummaryBatch.objects.create( + conversation=conversation, + user=owner, + batch_no="FS-VIEW", + status=FileSummaryBatch.Status.SUCCESS, + ) + monkeypatch.setattr("review_agent.application_form_fill.views.start_application_form_fill_workflow", lambda batch, async_run=True: None) + client.force_login(other) + + response = client.post( + reverse("application_form_fill_start"), + data=json.dumps({"conversation_id": conversation.pk}), + content_type="application/json", + ) + + assert response.status_code == 404 + + +def test_application_form_fill_start_creates_batch(client, monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-VIEW-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + monkeypatch.setattr("review_agent.application_form_fill.views.start_application_form_fill_workflow", lambda batch, async_run=True: None) + client.force_login(user) + + response = client.post( + reverse("application_form_fill_start"), + data=json.dumps({"conversation_id": conversation.pk, "template_codes": ["registration_certificate"]}), + content_type="application/json", + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["workflow_type"] == "application_form_fill" + assert ApplicationFormFillBatch.objects.filter(conversation=conversation).exists() + + +def test_application_form_fill_status_requires_owner_and_returns_nodes_exports(client, tmp_path, django_user_model): + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + conversation = Conversation.objects.create(user=owner, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=owner, batch_no="FS-STATUS") + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=owner, + source_summary_batch=summary, + batch_no="AFF-STATUS", + status=ApplicationFormFillBatch.Status.PARTIAL_SUCCESS, + selected_templates=["registration_certificate"], + conflict_summary=[{"field_key": "product_name"}], + risk_notes=[{"type": "pdf_pending"}], + ) + WorkflowNodeRun.objects.create( + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + node_group="form_fill", + node_code=FORM_FILL_NODE_DEFINITIONS[0][0], + node_name=FORM_FILL_NODE_DEFINITIONS[0][1], + status=WorkflowNodeRun.Status.SUCCESS, + progress=100, + ) + output = tmp_path / "filled.docx" + output.write_bytes(b"word") + exported = ExportedSummaryFile.objects.create( + batch=summary, + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + export_category="filled_template", + export_type=ExportedSummaryFile.ExportType.WORD, + file_name="filled.docx", + storage_path=str(output), + ) + + client.force_login(other) + denied = client.get(reverse("application_form_fill_batch_status", args=[batch.pk])) + assert denied.status_code == 404 + + client.force_login(owner) + allowed = client.get(reverse("application_form_fill_batch_status", args=[batch.pk])) + assert allowed.status_code == 200 + payload = allowed.json() + assert payload["batch"]["workflow_type"] == "application_form_fill" + assert payload["batch"]["status"] == ApplicationFormFillBatch.Status.PARTIAL_SUCCESS + assert payload["batch"]["conflict_count"] == 1 + assert payload["nodes"][0]["node_code"] == "prepare" + assert payload["exports"][0]["id"] == exported.pk diff --git a/tests/test_application_form_fill_word_fill.py b/tests/test_application_form_fill_word_fill.py new file mode 100644 index 0000000..2708e5d --- /dev/null +++ b/tests/test_application_form_fill_word_fill.py @@ -0,0 +1,182 @@ +import zipfile +from pathlib import Path + +import pytest +from docx import Document + +from review_agent.application_form_fill.schemas import MergedField, TemplateSpec +from review_agent.application_form_fill.services.word_fill import create_word_export, fill_template +from review_agent.models import ( + ApplicationFormFillArtifact, + ApplicationFormFillBatch, + Conversation, + ExportedSummaryFile, + FileSummaryBatch, +) + + +pytestmark = pytest.mark.django_db + + +def _spec(): + return TemplateSpec( + code="registration_certificate", + name="注册证格式", + source_file="template.docx", + output_label="注册证格式", + applies_when={"registration_type": ["首次注册"]}, + file_format="docx", + fields=[ + {"key": "product_name", "label": "产品名称", "target": {"type": "table_row", "row_label": "产品名称"}}, + {"key": "intended_use", "label": "预期用途", "target": {"type": "table_row", "row_label": "预期用途"}}, + ], + ) + + +def _template(path): + document = Document() + table = document.add_table(rows=2, cols=2) + table.rows[0].cells[0].text = "产品名称" + table.rows[1].cells[0].text = "预期用途" + document.save(path) + + +def _template_with_instructions(path): + document = Document() + table = document.add_table(rows=2, cols=2) + table.rows[0].cells[0].text = "产品名称" + table.rows[1].cells[0].text = "预期用途" + document.add_paragraph("填表说明") + document.add_paragraph("1. 本表依据《体外诊断注册与备案管理办法》制定。") + document.add_paragraph("2. 本表可从国家药品监督管理局网站下载。") + document.save(path) + + +def test_word_fill_writes_table_rows(tmp_path): + template_path = tmp_path / "template.docx" + output_path = tmp_path / "filled.docx" + _template(template_path) + + fill_template( + template_path, + output_path, + _spec(), + { + "product_name": MergedField("product_name", "产品名称", "甲胎蛋白检测试剂盒", "说明书.txt", "证据", 0.8), + "intended_use": MergedField("intended_use", "预期用途", "用于体外检测", "说明书.txt", "证据", 0.8), + }, + ) + + document = Document(output_path) + assert document.tables[0].rows[0].cells[1].text == "甲胎蛋白检测试剂盒" + assert document.tables[0].rows[1].cells[1].text == "用于体外检测" + + +def test_word_fill_removes_template_fill_instructions(tmp_path): + template_path = tmp_path / "template.docx" + output_path = tmp_path / "filled.docx" + _template_with_instructions(template_path) + + fill_template( + template_path, + output_path, + _spec(), + { + "product_name": MergedField("product_name", "产品名称", "甲胎蛋白检测试剂盒", "说明书.txt", "证据", 0.8), + }, + ) + + document = Document(output_path) + text = "\n".join(paragraph.text for paragraph in document.paragraphs) + assert "填表说明" not in text + assert "本表依据" not in text + assert document.tables[0].rows[0].cells[1].text == "甲胎蛋白检测试剂盒" + + +def test_word_fill_highlights_conflict_in_docx_xml(tmp_path): + template_path = tmp_path / "template.docx" + output_path = tmp_path / "filled.docx" + _template(template_path) + + fill_template( + template_path, + output_path, + _spec(), + { + "product_name": MergedField( + "product_name", + "产品名称", + "甲胎蛋白检测试剂盒", + "说明书.txt", + "证据", + 0.8, + has_conflict=True, + ) + }, + conflicts=[{"field_key": "product_name"}], + ) + + with zipfile.ZipFile(output_path) as package: + document_xml = package.read("word/document.xml").decode("utf-8") + assert 'w:fill="FFFF00"' in document_xml + assert 'w:color w:val="FF0000"' in document_xml + + +def test_create_word_export_records_artifact_and_export(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-WORD") + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="AFF-WORD", + product_name="甲胎蛋白检测试剂盒", + work_dir=str(tmp_path / "aff" / "AFF-WORD"), + ) + template_path = tmp_path / "template.docx" + _template(template_path) + + exported = create_word_export( + batch, + _spec(), + template_path, + {"product_name": MergedField("product_name", "产品名称", "甲胎蛋白检测试剂盒", "说明书.txt", "证据", 0.8)}, + ) + + assert exported.export_type == ExportedSummaryFile.ExportType.WORD + assert exported.workflow_type == "application_form_fill" + assert exported.workflow_batch_id == batch.pk + assert ApplicationFormFillArtifact.objects.filter( + batch=batch, + artifact_type=ApplicationFormFillArtifact.ArtifactType.FILLED_TEMPLATE, + ).exists() + + +def test_create_word_export_sanitizes_product_name_newlines(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-WORD-NL") + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="AFF-WORD-NL", + product_name="原体核酸检测试剂盒(荧\n光PCR法)", + work_dir=str(tmp_path / "aff" / "AFF-WORD-NL"), + ) + template_path = tmp_path / "template.docx" + _template(template_path) + + exported = create_word_export( + batch, + _spec(), + template_path, + {"product_name": MergedField("product_name", "产品名称", "原体核酸检测试剂盒", "说明书.txt", "证据", 0.8)}, + ) + + assert "\n" not in exported.file_name + assert "\r" not in exported.file_name + assert Path(exported.storage_path).exists() diff --git a/tests/test_application_form_fill_workflow.py b/tests/test_application_form_fill_workflow.py new file mode 100644 index 0000000..fec7d38 --- /dev/null +++ b/tests/test_application_form_fill_workflow.py @@ -0,0 +1,333 @@ +import pytest + +from review_agent.application_form_fill.constants import FORM_FILL_NODE_DEFINITIONS +from review_agent.application_form_fill.workflow import ( + create_application_form_fill_batch, + find_latest_successful_summary_batch, + start_application_form_fill_workflow, +) +from review_agent.models import ( + ApplicationFormFillBatch, + Conversation, + FileAttachment, + FileSummaryBatch, + FileSummaryBatchAttachment, + Message, + WorkflowEvent, + WorkflowNodeRun, +) +from review_agent.services import stream_message +from review_agent.skill_router import SkillRoute + + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def stub_aff_llm_extract(monkeypatch): + monkeypatch.setattr( + "review_agent.application_form_fill.services.field_extract.generate_completion", + lambda messages, temperature=0.0: '{"fields": [], "checklist_items": []}', + ) + + +def test_find_latest_successful_summary_batch_ignores_failed_batches(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + success = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-AFF-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-AFF-FAILED", + status=FileSummaryBatch.Status.FAILED, + ) + + assert find_latest_successful_summary_batch(conversation) == success + + +def test_create_application_form_fill_batch_initializes_nodes(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + message = Message.objects.create(conversation=conversation, role=Message.Role.USER, content="帮我填注册证") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-AFF-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + + batch = create_application_form_fill_batch( + conversation=conversation, + user=user, + trigger_message=message, + source_summary_batch=summary, + ) + + assert batch.status == ApplicationFormFillBatch.Status.PENDING + assert batch.output_types == ["word", "excel", "json"] + assert WorkflowNodeRun.objects.filter( + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + ).count() == len(FORM_FILL_NODE_DEFINITIONS) + assert WorkflowEvent.objects.filter( + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + event_type="workflow_created", + ).exists() + + +def test_application_form_fill_executor_runs_nodes_and_skips_pdf(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-AFF-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + trigger = Message.objects.create(conversation=conversation, role=Message.Role.USER, content="帮我填注册证") + batch = create_application_form_fill_batch( + conversation=conversation, + user=user, + trigger_message=trigger, + source_summary_batch=summary, + ) + + start_application_form_fill_workflow(batch, async_run=False) + + batch.refresh_from_db() + assert batch.status == ApplicationFormFillBatch.Status.SUCCESS + assert WorkflowNodeRun.objects.get( + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + node_code="pdf_convert", + ).status == WorkflowNodeRun.Status.SKIPPED + assert WorkflowEvent.objects.filter( + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + event_type="workflow_completed", + ).exists() + + +def test_application_form_fill_workflow_generates_summary_and_exports(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + settings.APPLICATION_FORM_FILL_ASYNC = False + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + trigger = Message.objects.create(conversation=conversation, role=Message.Role.USER, content="帮我填注册证") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-AFF-FULL", + status=FileSummaryBatch.Status.SUCCESS, + ) + source = tmp_path / "ifu.txt" + source.write_text("产品名称:甲胎蛋白检测试剂盒\n预期用途:用于体外检测", encoding="utf-8") + from review_agent.models import FileSummaryItem + + FileSummaryItem.objects.create( + batch=summary, + file_index=1, + file_name="说明书.txt", + file_type="txt", + relative_path="说明书.txt", + storage_path=str(source), + ) + batch = create_application_form_fill_batch( + conversation=conversation, + user=user, + trigger_message=trigger, + source_summary_batch=summary, + ) + + start_application_form_fill_workflow(batch, async_run=False) + + batch.refresh_from_db() + assert batch.status == ApplicationFormFillBatch.Status.SUCCESS + assert batch.product_name == "甲胎蛋白检测试剂盒" + assert batch.selected_templates == ["registration_certificate"] + assert conversation.messages.filter(role=Message.Role.ASSISTANT, content__contains="已生成申报模板自动填表文件").exists() + assert batch.notifications.filter(send_status="success").exists() + + +def test_application_form_fill_status_becomes_partial_when_notification_fails(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + settings.APPLICATION_FORM_FILL_MOCK_NOTIFY_FAIL = True + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + trigger = Message.objects.create(conversation=conversation, role=Message.Role.USER, content="帮我填注册证") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-AFF-PARTIAL", + status=FileSummaryBatch.Status.SUCCESS, + ) + batch = create_application_form_fill_batch( + conversation=conversation, + user=user, + trigger_message=trigger, + source_summary_batch=summary, + ) + + start_application_form_fill_workflow(batch, async_run=False) + + batch.refresh_from_db() + assert batch.status == ApplicationFormFillBatch.Status.PARTIAL_SUCCESS + assert batch.notifications.filter(send_status="failed").exists() + + +def test_stream_message_prompts_for_upload_when_no_summary_or_attachment(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + monkeypatch.setattr( + "review_agent.services.route_message_intent", + lambda conversation, content: SkillRoute( + action="application_form_fill", + workflow_type="application_form_fill", + confidence=0.9, + ), + ) + + frames = list(stream_message(conversation, "帮我填注册证")) + + joined = "".join(frames) + assert "请先在当前对话右侧上传需要填表的产品资料或压缩包" in joined + assert not ApplicationFormFillBatch.objects.exists() + + +def test_stream_message_starts_application_form_fill_workflow(monkeypatch, settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + settings.APPLICATION_FORM_FILL_ASYNC = False + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-AFF-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + monkeypatch.setattr( + "review_agent.services.route_message_intent", + lambda conversation, content: SkillRoute( + action="application_form_fill", + workflow_type="application_form_fill", + confidence=0.9, + ), + ) + + frames = list(stream_message(conversation, "帮我填注册证")) + + joined = "".join(frames) + assert "workflow_started" in joined + assert '"workflow_type": "application_form_fill"' in joined + assert "已启动申报文件自动填表工作流" in joined + assert ApplicationFormFillBatch.objects.filter(conversation=conversation).exists() + + +def test_stream_message_auto_runs_summary_before_application_form_fill( + monkeypatch, settings, tmp_path, django_user_model +): + settings.MEDIA_ROOT = tmp_path + settings.APPLICATION_FORM_FILL_ASYNC = False + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + attachment_path = tmp_path / "application.txt" + attachment_path.write_text("产品名称:甲胎蛋白检测试剂盒", encoding="utf-8") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="application.txt", + storage_path=str(attachment_path), + file_size=attachment_path.stat().st_size, + is_active=True, + ) + monkeypatch.setattr( + "review_agent.services.route_message_intent", + lambda conversation, content: SkillRoute( + action="application_form_fill", + workflow_type="application_form_fill", + confidence=0.9, + ), + ) + + def finish_summary(batch, async_run=True): + batch.status = FileSummaryBatch.Status.SUCCESS + batch.save(update_fields=["status"]) + + monkeypatch.setattr("review_agent.services.start_file_summary_workflow", finish_summary) + + frames = list(stream_message(conversation, "为我该方案生成申报模板")) + joined = "".join(frames) + + assert '"workflow_type": "file_summary"' in joined + assert '"workflow_type": "application_form_fill"' in joined + assert "汇总完成后继续自动填表" in joined + assert FileSummaryBatch.objects.filter(conversation=conversation, status=FileSummaryBatch.Status.SUCCESS).exists() + assert ApplicationFormFillBatch.objects.filter(conversation=conversation).exists() + + +def test_stream_message_reruns_summary_when_new_attachment_not_in_latest_batch( + monkeypatch, settings, tmp_path, django_user_model +): + settings.MEDIA_ROOT = tmp_path + settings.APPLICATION_FORM_FILL_ASYNC = False + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + old_path = tmp_path / "old.txt" + old_path.write_text("旧资料", encoding="utf-8") + old_attachment = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="旧资料.txt", + storage_path=str(old_path), + file_size=old_path.stat().st_size, + is_active=True, + ) + old_summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OLD", + status=FileSummaryBatch.Status.SUCCESS, + ) + FileSummaryBatchAttachment.objects.create(batch=old_summary, attachment=old_attachment) + new_path = tmp_path / "ifu.txt" + new_path.write_text("【产品名称】\n新型冠状病毒2019-nCoV核酸检测试剂盒(荧光PCR法)", encoding="utf-8") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="目标产品说明书.docx", + storage_path=str(new_path), + file_size=new_path.stat().st_size, + is_active=True, + ) + monkeypatch.setattr( + "review_agent.services.route_message_intent", + lambda conversation, content: SkillRoute( + action="application_form_fill", + workflow_type="application_form_fill", + confidence=0.9, + ), + ) + + def finish_summary(batch, async_run=True): + batch.status = FileSummaryBatch.Status.SUCCESS + batch.save(update_fields=["status"]) + + monkeypatch.setattr("review_agent.services.start_file_summary_workflow", finish_summary) + + frames = list(stream_message(conversation, "请基于当前对话最近成功汇总的产品资料,自动提取产品关键信息并填入申报文件模板")) + joined = "".join(frames) + + assert '"workflow_type": "file_summary"' in joined + assert "汇总完成后继续自动填表" in joined + latest_summary = FileSummaryBatch.objects.order_by("-id").first() + form_batch = ApplicationFormFillBatch.objects.get(conversation=conversation) + assert latest_summary != old_summary + assert form_batch.source_summary_batch == latest_summary diff --git a/tests/test_attachment_reader.py b/tests/test_attachment_reader.py new file mode 100644 index 0000000..84afb97 --- /dev/null +++ b/tests/test_attachment_reader.py @@ -0,0 +1,146 @@ +from pathlib import Path + +import pytest +from django.conf import settings + +from review_agent.models import Conversation, FileAttachment + + +pytestmark = pytest.mark.django_db + + +def test_read_attachment_extracts_text_file_details(settings, tmp_path, django_user_model): + from review_agent.file_summary.services.attachment_reader import read_attachment_details + + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + relative_path = Path("uploads") / "note.txt" + absolute_path = tmp_path / relative_path + absolute_path.parent.mkdir(parents=True) + absolute_path.write_text("产品名称:智能审核\n关键结论:可以解析附件详情", encoding="utf-8") + attachment = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="note.txt", + storage_path=relative_path.as_posix(), + file_size=absolute_path.stat().st_size, + content_type="text/plain", + ) + + result = read_attachment_details(attachment) + + assert result.status == "success" + assert result.filename == "note.txt" + assert result.file_type == "txt" + assert "智能审核" in result.preview_text + assert result.sections[0]["type"] == "text" + + +def test_read_attachment_extracts_docx_and_xlsx_details(settings, tmp_path, django_user_model): + from docx import Document + from openpyxl import Workbook + + from review_agent.file_summary.services.attachment_reader import read_attachment_details + + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + docx_path = tmp_path / "uploads" / "summary.docx" + docx_path.parent.mkdir(parents=True) + doc = Document() + doc.add_heading("项目摘要", level=1) + doc.add_paragraph("这是 Word 附件里的正文。") + doc.save(docx_path) + docx_attachment = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="summary.docx", + storage_path="uploads/summary.docx", + file_size=docx_path.stat().st_size, + ) + + workbook_path = tmp_path / "uploads" / "inventory.xlsx" + workbook = Workbook() + sheet = workbook.active + sheet.title = "清单" + sheet.append(["文件名", "页数"]) + sheet.append(["a.pdf", 3]) + workbook.save(workbook_path) + xlsx_attachment = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="inventory.xlsx", + storage_path="uploads/inventory.xlsx", + file_size=workbook_path.stat().st_size, + ) + + docx_result = read_attachment_details(docx_attachment) + xlsx_result = read_attachment_details(xlsx_attachment) + + assert docx_result.status == "success" + assert "项目摘要" in docx_result.preview_text + assert "Word 附件里的正文" in docx_result.preview_text + assert xlsx_result.status == "success" + assert xlsx_result.sections[0]["name"] == "清单" + assert xlsx_result.sections[0]["rows"][1] == ["a.pdf", "3"] + + +def test_attachment_reader_skill_returns_structured_details(settings, tmp_path, django_user_model): + from review_agent.file_summary.skills.attachment_reader import AttachmentReaderSkill + + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + file_path = tmp_path / "uploads" / "readme.txt" + file_path.parent.mkdir(parents=True) + file_path.write_text("请读取这个附件。", encoding="utf-8") + attachment = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="readme.txt", + storage_path="uploads/readme.txt", + file_size=file_path.stat().st_size, + ) + + result = AttachmentReaderSkill().run_for_attachments([attachment]) + + assert result.success is True + assert result.data["attachments"][0]["filename"] == "readme.txt" + assert "请读取这个附件" in result.data["attachments"][0]["preview_text"] + + +def test_read_attachment_extracts_files_inside_rar(monkeypatch, settings, tmp_path, django_user_model): + from review_agent.file_summary.services.attachment_reader import read_attachment_details + + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + archive_path = tmp_path / "uploads" / "第1章_监管信息.rar" + archive_path.parent.mkdir(parents=True) + archive_path.write_bytes(b"rar") + attachment = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="第1章_监管信息.rar", + storage_path="uploads/第1章_监管信息.rar", + file_size=archive_path.stat().st_size, + ) + + def fake_extract_archive(path: Path, target_dir: Path): + extracted = target_dir / "说明书.txt" + extracted.write_text("产品名称:甲胎蛋白检测试剂盒", encoding="utf-8") + return [extracted] + + monkeypatch.setattr( + "review_agent.file_summary.services.attachment_reader.extract_archive", + fake_extract_archive, + ) + + result = read_attachment_details(attachment) + + assert result.status == "success" + assert result.file_type == "rar" + assert "说明书.txt" in result.sections[0]["name"] + assert "甲胎蛋白检测试剂盒" in result.preview_text diff --git a/tests/test_chat_knowledge_context.py b/tests/test_chat_knowledge_context.py new file mode 100644 index 0000000..af12f02 --- /dev/null +++ b/tests/test_chat_knowledge_context.py @@ -0,0 +1,123 @@ +import pytest + +from review_agent.models import KnowledgeBaseDocument +from review_agent.services import build_knowledge_context, send_message, stream_message + + +pytestmark = pytest.mark.django_db + + +def test_build_knowledge_context_ignores_irrelevant_rag_chunks(monkeypatch): + monkeypatch.setattr( + "review_agent.services.search_knowledge_base", + lambda query, n_results=5: { + "query": query, + "results": [ + { + "source": "附件 4 体外诊断试剂注册申报资料要求及说明.doc", + "text": "预期用途应明确产品用于检测的分析物和功能。", + "score": 7.636, + "metadata": {"source_type": "regulatory_document"}, + } + ], + "error_message": "", + }, + ) + + context = build_knowledge_context("孙之烨是谁") + + assert context == "" + + +def test_build_knowledge_context_uses_full_document_when_name_matches(settings, tmp_path, monkeypatch, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + document_path = tmp_path / "resume.txt" + document_path.write_text( + "孙之烨,负责审核智能体项目。\n完整经历:曾组织技术分享并带队参加竞赛。", + encoding="utf-8", + ) + KnowledgeBaseDocument.objects.create( + user=user, + display_name="孙之烨简历", + original_name="孙之烨-260510.txt", + storage_path=str(document_path), + file_size=document_path.stat().st_size, + status=KnowledgeBaseDocument.Status.ACTIVE, + is_active=True, + indexed_chunk_count=2, + ) + monkeypatch.setattr( + "review_agent.services.search_knowledge_base", + lambda query, n_results=5: {"query": query, "results": [], "error_message": ""}, + ) + + context = build_knowledge_context("孙之烨是谁") + + assert "全文材料" in context + assert "来源:用户知识库/孙之烨-260510.txt" in context + assert "完整经历:曾组织技术分享并带队参加竞赛" in context + + +def test_send_message_refuses_out_of_scope_answer_without_knowledge_context(monkeypatch, django_user_model): + from review_agent.models import Conversation + + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + monkeypatch.setattr( + "review_agent.services.search_knowledge_base", + lambda query, n_results=5: {"query": query, "results": [], "error_message": ""}, + ) + monkeypatch.setattr( + "review_agent.services.generate_reply", + lambda *args, **kwargs: pytest.fail("out-of-scope answer without knowledge context must not call LLM"), + ) + + _, assistant_message = send_message(conversation, "孙之烨是谁") + + assert "没有在当前启用的知识库材料中找到" in assistant_message.content + assert "与当前主营业务无关" in assistant_message.content + + +def test_stream_message_refuses_out_of_scope_answer_without_knowledge_context(monkeypatch, django_user_model): + from review_agent.models import Conversation + + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + monkeypatch.setattr( + "review_agent.services.search_knowledge_base", + lambda query, n_results=5: {"query": query, "results": [], "error_message": ""}, + ) + monkeypatch.setattr( + "review_agent.services.stream_reply", + lambda *args, **kwargs: pytest.fail("out-of-scope answer without knowledge context must not call streaming LLM"), + ) + monkeypatch.setattr( + "review_agent.services.generate_reply", + lambda *args, **kwargs: pytest.fail("out-of-scope answer without knowledge context must not call fallback LLM"), + ) + + frames = list(stream_message(conversation, "给我一份红烧肉菜谱")) + + assert any("没有在当前启用的知识库材料中找到" in frame for frame in frames) + assert any("与当前主营业务无关" in frame for frame in frames) + assert any("done" in frame for frame in frames) + + +def test_business_question_without_knowledge_context_can_use_llm(monkeypatch, django_user_model): + from review_agent.models import Conversation + + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + monkeypatch.setattr( + "review_agent.services.search_knowledge_base", + lambda query, n_results=5: {"query": query, "results": [], "error_message": ""}, + ) + monkeypatch.setattr( + "review_agent.services.generate_reply", + lambda *args, **kwargs: "注册检验报告通常用于证明产品性能符合要求。", + ) + + _, assistant_message = send_message(conversation, "注册检验报告有什么作用") + + assert "注册检验报告" in assistant_message.content diff --git a/tests/test_feishu_api_services.py b/tests/test_feishu_api_services.py new file mode 100644 index 0000000..355558a --- /dev/null +++ b/tests/test_feishu_api_services.py @@ -0,0 +1,202 @@ +import json + +from django.utils import timezone +import pytest + +from review_agent.models import FeishuAccessTokenCache +from review_agent.notifications.context import NotificationContext +from review_agent.notifications.feishu_message_api import send_personal_message +from review_agent.notifications.feishu_token import app_id_hash, get_tenant_access_token +from review_agent.notifications.message_builder import build_feishu_post_message +from review_agent.notifications.recipient import resolve_configured_personal_recipient + + +pytestmark = pytest.mark.django_db + + +class FakeResponse: + def __init__(self, payload, status_code=200): + self.payload = payload + self.status_code = status_code + self.text = json.dumps(payload, ensure_ascii=False) + + def json(self): + return self.payload + + +def test_token_service_fetches_and_caches(monkeypatch, settings): + settings.FEISHU_APP_ID = "cli_a" + settings.FEISHU_APP_SECRET = "secret" + calls = [] + + def fake_post(*args, **kwargs): + calls.append(kwargs) + return FakeResponse({"code": 0, "tenant_access_token": "tenant-token", "expire": 7200}) + + monkeypatch.setattr("review_agent.notifications.feishu_token.httpx.post", fake_post) + + first = get_tenant_access_token() + second = get_tenant_access_token() + + assert first.ok + assert second.tenant_access_token == "tenant-token" + assert len(calls) == 1 + assert FeishuAccessTokenCache.objects.get(app_id_hash=app_id_hash("cli_a")).is_valid() + + +def test_token_service_refreshes_expired_cache(monkeypatch, settings): + settings.FEISHU_APP_ID = "cli_a" + settings.FEISHU_APP_SECRET = "secret" + FeishuAccessTokenCache.objects.create( + app_id_hash=app_id_hash("cli_a"), + tenant_access_token="old", + expires_at=timezone.now() - timezone.timedelta(minutes=1), + ) + + monkeypatch.setattr( + "review_agent.notifications.feishu_token.httpx.post", + lambda *args, **kwargs: FakeResponse({"code": 0, "tenant_access_token": "new", "expire": 7200}), + ) + + assert get_tenant_access_token().tenant_access_token == "new" + + +def test_token_service_returns_error_for_api_failure(monkeypatch, settings): + settings.FEISHU_APP_ID = "cli_a" + settings.FEISHU_APP_SECRET = "secret" + monkeypatch.setattr( + "review_agent.notifications.feishu_token.httpx.post", + lambda *args, **kwargs: FakeResponse({"code": 1, "msg": "bad secret"}), + ) + + result = get_tenant_access_token() + + assert not result.ok + assert result.error_message == "bad secret" + + +def test_recipient_prefers_open_id(settings): + settings.FEISHU_DEFAULT_USER_OPEN_ID = "ou_xxx" + settings.FEISHU_DEFAULT_USER_ID = "user_xxx" + settings.FEISHU_DEFAULT_TARGET_NAME = "负责人" + + target = resolve_configured_personal_recipient() + + assert target.ok + assert target.identifier_type == "open_id" + assert target.identifier_value == "ou_xxx" + + +def test_recipient_uses_user_id_when_open_id_missing(settings): + settings.FEISHU_DEFAULT_USER_OPEN_ID = "" + settings.FEISHU_DEFAULT_USER_ID = "user_xxx" + + target = resolve_configured_personal_recipient() + + assert target.ok + assert target.identifier_type == "user_id" + + +def test_recipient_missing(settings): + settings.FEISHU_DEFAULT_USER_OPEN_ID = "" + settings.FEISHU_DEFAULT_USER_ID = "" + + target = resolve_configured_personal_recipient() + + assert not target.ok + assert target.error_code == "recipient_missing" + + +def test_build_feishu_post_message_contains_summary(settings): + settings.PUBLIC_BASE_URL = "http://example.test" + settings.FEISHU_DEFAULT_USER_OPEN_ID = "ou_xxx" + target = resolve_configured_personal_recipient() + context = NotificationContext( + workflow_type="file_summary", + workflow_name="自动汇总", + workflow_batch_id=1, + workflow_batch_no="FS-001", + workflow_status="success", + trigger_user_id=1, + trigger_username="owner", + title="自动汇总完成", + summary_lines=("文件 3 个", "异常 0 个"), + next_step="查看汇总结果", + result_path="/summary/1/", + ) + + payload = build_feishu_post_message(context, target) + + assert payload["receive_id"] == "ou_xxx" + content = json.loads(payload["content"]) + assert content["zh_cn"]["title"] == "自动汇总完成" + assert "http://example.test/summary/1/" in payload["content"] + + +def test_send_personal_message_success(monkeypatch, settings): + settings.FEISHU_MESSAGE_API_URL = "http://feishu/messages" + requests = [] + + def fake_post(*args, **kwargs): + requests.append(kwargs) + return FakeResponse({"code": 0, "data": {"message_id": "om_xxx"}}) + + monkeypatch.setattr("review_agent.notifications.feishu_message_api.httpx.post", fake_post) + + result = send_personal_message( + tenant_access_token="token", + receive_id_type="open_id", + payload={"receive_id": "ou_xxx"}, + ) + + assert result.ok + assert result.external_message_id == "om_xxx" + assert requests[0]["headers"]["Authorization"] == "Bearer token" + + +def test_send_personal_message_api_error(monkeypatch, settings): + settings.FEISHU_MESSAGE_API_URL = "http://feishu/messages" + monkeypatch.setattr( + "review_agent.notifications.feishu_message_api.httpx.post", + lambda *args, **kwargs: FakeResponse({"code": 230001, "msg": "bad receive_id"}), + ) + + result = send_personal_message( + tenant_access_token="token", + receive_id_type="open_id", + payload={"receive_id": "bad"}, + ) + + assert not result.ok + assert result.error_code == "230001" + + +def test_send_personal_message_refreshes_token_once(monkeypatch, settings): + settings.FEISHU_MESSAGE_API_URL = "http://feishu/messages" + calls = {"message": 0} + + def fake_message_post(*args, **kwargs): + calls["message"] += 1 + if calls["message"] == 1: + return FakeResponse({"code": 99991663, "msg": "token expired"}) + return FakeResponse({"code": 0, "data": {"message_id": "om_retry"}}) + + monkeypatch.setattr("review_agent.notifications.feishu_message_api.httpx.post", fake_message_post) + monkeypatch.setattr( + "review_agent.notifications.feishu_message_api.get_tenant_access_token", + lambda force_refresh=False: type( + "TokenResult", + (), + {"ok": True, "tenant_access_token": "fresh", "error_code": "", "error_message": ""}, + )(), + ) + + result = send_personal_message( + tenant_access_token="stale", + receive_id_type="open_id", + payload={"receive_id": "ou_xxx"}, + ) + + assert result.ok + assert result.refreshed_token + assert calls["message"] == 2 diff --git a/tests/test_feishu_management_commands.py b/tests/test_feishu_management_commands.py new file mode 100644 index 0000000..cabc1e5 --- /dev/null +++ b/tests/test_feishu_management_commands.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass +from io import StringIO + +from django.core.management import call_command +from django.core.management.base import CommandError +import pytest + + +pytestmark = pytest.mark.django_db + + +@dataclass(frozen=True) +class FakeRecord: + send_status: str = "success" + target: str = "负责人" + error_message: str = "" + + +def test_send_test_feishu_notification_calls_dispatcher(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + calls = [] + monkeypatch.setattr( + "review_agent.management.commands.send_test_feishu_notification.dispatch_workflow_notification", + lambda context: calls.append(context) or FakeRecord(), + ) + output = StringIO() + + call_command("send_test_feishu_notification", "--username", user.username, stdout=output) + + assert calls + assert calls[0].workflow_type == "manual_test" + assert calls[0].trigger_user_id == user.pk + assert "send_status=success" in output.getvalue() + assert "target=负责人" in output.getvalue() + + +def test_send_test_feishu_notification_missing_user_raises(): + with pytest.raises(CommandError): + call_command("send_test_feishu_notification", "--username", "missing") diff --git a/tests/test_feishu_models.py b/tests/test_feishu_models.py new file mode 100644 index 0000000..95c6657 --- /dev/null +++ b/tests/test_feishu_models.py @@ -0,0 +1,104 @@ +from django.utils import timezone +import pytest + +from review_agent.models import ( + Conversation, + FeishuAccessTokenCache, + FeishuQuestionLog, + FeishuUserMapping, + FileSummaryBatch, + WorkflowNotificationRecord, +) + + +pytestmark = pytest.mark.django_db + + +def test_feishu_user_mapping_preferred_identifier(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + mapping = FeishuUserMapping.objects.create( + system_user=user, + feishu_display_name="负责人", + feishu_open_id="ou_open", + feishu_user_id="user_id", + feishu_mobile="13800000000", + ) + + assert mapping.preferred_identifier() == ("open_id", "ou_open") + + mapping.feishu_open_id = "" + assert mapping.preferred_identifier() == ("user_id", "user_id") + + mapping.feishu_user_id = "" + assert mapping.preferred_identifier() == ("mobile", "13800000000") + + +def test_feishu_access_token_cache_expiry(): + now = timezone.now() + cache = FeishuAccessTokenCache.objects.create( + app_id_hash="hash", + tenant_access_token="token", + expires_at=now + timezone.timedelta(minutes=5), + ) + + assert cache.is_valid(now=now) + + cache.expires_at = now - timezone.timedelta(seconds=1) + assert not cache.is_valid(now=now) + + +def test_workflow_notification_success_dedupe_only_success(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="飞书") + batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-FEISHU", + status=FileSummaryBatch.Status.SUCCESS, + ) + dedupe_key = WorkflowNotificationRecord.build_dedupe_key("file_summary", batch.pk, "success") + WorkflowNotificationRecord.objects.create( + workflow_type="file_summary", + workflow_batch_id=batch.pk, + workflow_batch_no=batch.batch_no, + workflow_status="success", + dedupe_key=dedupe_key, + trigger_user=user, + channel=WorkflowNotificationRecord.Channel.FEISHU_API, + send_status=WorkflowNotificationRecord.SendStatus.FAILED, + message_title="失败通知", + ) + + assert not WorkflowNotificationRecord.already_successfully_sent(dedupe_key) + + WorkflowNotificationRecord.objects.create( + workflow_type="file_summary", + workflow_batch_id=batch.pk, + workflow_batch_no=batch.batch_no, + workflow_status="success", + dedupe_key=dedupe_key, + trigger_user=user, + channel=WorkflowNotificationRecord.Channel.FEISHU_API, + send_status=WorkflowNotificationRecord.SendStatus.SUCCESS, + message_title="成功通知", + ) + + assert WorkflowNotificationRecord.already_successfully_sent(dedupe_key) + + +def test_feishu_question_log_records_summary_without_full_answer(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + log = FeishuQuestionLog.objects.create( + system_user=user, + source_type=FeishuQuestionLog.SourceType.SIMULATE, + question_text="查最新法规核查", + intent="batch_status", + query_object={"workflow_type": "regulatory_review", "latest": True}, + answer_summary="RR-001 成功,阻断项 0,高风险 1。", + permission_result="allowed", + status=FeishuQuestionLog.Status.SUCCESS, + processed_at=timezone.now(), + ) + + assert "完整回答" not in log.answer_summary + assert log.query_object["latest"] is True diff --git a/tests/test_feishu_notification_dispatcher.py b/tests/test_feishu_notification_dispatcher.py new file mode 100644 index 0000000..39dc940 --- /dev/null +++ b/tests/test_feishu_notification_dispatcher.py @@ -0,0 +1,160 @@ +from dataclasses import dataclass + +import pytest + +from review_agent.models import Conversation, FileSummaryBatch, WorkflowNotificationRecord +from review_agent.notifications.context import NotificationContext +from review_agent.notifications.dispatcher import dispatch_workflow_notification + + +pytestmark = pytest.mark.django_db + + +@dataclass(frozen=True) +class FakeTokenResult: + ok: bool + tenant_access_token: str = "" + error_code: str = "" + error_message: str = "" + + +@dataclass(frozen=True) +class FakeSendResult: + ok: bool + external_message_id: str = "" + error_code: str = "" + error_message: str = "" + request_duration_ms: int | None = None + + +def _context(user, batch): + return NotificationContext( + workflow_type="file_summary", + workflow_name="自动汇总", + workflow_batch_id=batch.pk, + workflow_batch_no=batch.batch_no, + workflow_status=batch.status, + trigger_user_id=user.pk, + trigger_username=user.username, + title="自动汇总完成", + summary_lines=("文件 1 个",), + next_step="查看汇总", + result_path=f"/file-summary/{batch.pk}/", + ) + + +def _batch(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="飞书") + batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-DISPATCH", + status=FileSummaryBatch.Status.SUCCESS, + ) + return user, batch + + +def test_dispatch_disabled_writes_record_without_api_call(django_user_model, settings, monkeypatch): + user, batch = _batch(django_user_model) + settings.FEISHU_NOTIFY_ENABLED = False + + def fail_call(*args, **kwargs): + raise AssertionError("should not call external service") + + monkeypatch.setattr("review_agent.notifications.dispatcher.send_personal_message", fail_call) + + record = dispatch_workflow_notification(_context(user, batch)) + + assert record.send_status == WorkflowNotificationRecord.SendStatus.DISABLED + assert record.channel == WorkflowNotificationRecord.Channel.DISABLED + + +def test_dispatch_success_writes_success_record(django_user_model, settings, monkeypatch): + user, batch = _batch(django_user_model) + settings.FEISHU_NOTIFY_ENABLED = True + settings.FEISHU_DEFAULT_USER_OPEN_ID = "ou_xxx" + monkeypatch.setattr( + "review_agent.notifications.dispatcher.get_tenant_access_token", + lambda: FakeTokenResult(ok=True, tenant_access_token="token"), + ) + monkeypatch.setattr( + "review_agent.notifications.dispatcher.send_personal_message", + lambda **kwargs: FakeSendResult(ok=True, external_message_id="om_xxx", request_duration_ms=12), + ) + + record = dispatch_workflow_notification(_context(user, batch)) + + assert record.send_status == WorkflowNotificationRecord.SendStatus.SUCCESS + assert record.external_message_id == "om_xxx" + assert record.sent_at is not None + + +def test_dispatch_existing_success_skips_api(django_user_model, settings, monkeypatch): + user, batch = _batch(django_user_model) + settings.FEISHU_NOTIFY_ENABLED = True + context = _context(user, batch) + existing = WorkflowNotificationRecord.objects.create( + workflow_type=context.workflow_type, + workflow_batch_id=context.workflow_batch_id, + workflow_batch_no=context.workflow_batch_no, + workflow_status=context.workflow_status, + dedupe_key=context.dedupe_key, + trigger_user=user, + channel=WorkflowNotificationRecord.Channel.FEISHU_API, + send_status=WorkflowNotificationRecord.SendStatus.SUCCESS, + message_title=context.title, + ) + + def fail_call(*args, **kwargs): + raise AssertionError("duplicate should not call API") + + monkeypatch.setattr("review_agent.notifications.dispatcher.send_personal_message", fail_call) + + assert dispatch_workflow_notification(context).pk == existing.pk + + +def test_dispatch_existing_failed_allows_retry(django_user_model, settings, monkeypatch): + user, batch = _batch(django_user_model) + settings.FEISHU_NOTIFY_ENABLED = True + settings.FEISHU_DEFAULT_USER_OPEN_ID = "ou_xxx" + context = _context(user, batch) + WorkflowNotificationRecord.objects.create( + workflow_type=context.workflow_type, + workflow_batch_id=context.workflow_batch_id, + workflow_batch_no=context.workflow_batch_no, + workflow_status=context.workflow_status, + dedupe_key=context.dedupe_key, + trigger_user=user, + channel=WorkflowNotificationRecord.Channel.FEISHU_API, + send_status=WorkflowNotificationRecord.SendStatus.FAILED, + message_title=context.title, + ) + monkeypatch.setattr( + "review_agent.notifications.dispatcher.get_tenant_access_token", + lambda: FakeTokenResult(ok=True, tenant_access_token="token"), + ) + monkeypatch.setattr( + "review_agent.notifications.dispatcher.send_personal_message", + lambda **kwargs: FakeSendResult(ok=True, external_message_id="om_retry"), + ) + + record = dispatch_workflow_notification(context) + + assert record.send_status == WorkflowNotificationRecord.SendStatus.SUCCESS + assert WorkflowNotificationRecord.objects.filter(dedupe_key=context.dedupe_key).count() == 2 + + +def test_dispatch_token_failure_writes_failed(django_user_model, settings, monkeypatch): + user, batch = _batch(django_user_model) + settings.FEISHU_NOTIFY_ENABLED = True + settings.FEISHU_DEFAULT_USER_OPEN_ID = "ou_xxx" + monkeypatch.setattr( + "review_agent.notifications.dispatcher.get_tenant_access_token", + lambda: FakeTokenResult(ok=False, error_code="token_error", error_message="bad secret"), + ) + + record = dispatch_workflow_notification(_context(user, batch)) + + assert record.send_status == WorkflowNotificationRecord.SendStatus.FAILED + assert record.error_code == "token_error" diff --git a/tests/test_feishu_question_reserved.py b/tests/test_feishu_question_reserved.py new file mode 100644 index 0000000..07b01e4 --- /dev/null +++ b/tests/test_feishu_question_reserved.py @@ -0,0 +1,92 @@ +from io import StringIO + +from django.core.management import call_command +import pytest + +from review_agent.feishu_questions.intent import parse_question_intent +from review_agent.feishu_questions.query import query_batch_summary +from review_agent.feishu_questions.service import answer_question +from review_agent.models import Conversation, FeishuQuestionLog, FileSummaryBatch, RegulatoryReviewBatch + + +pytestmark = pytest.mark.django_db + + +def test_query_latest_regulatory_batch_for_owner(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-001") + RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-001", + status=RegulatoryReviewBatch.Status.SUCCESS, + risk_summary={"blocking": 0, "high": 1}, + ) + + result = query_batch_summary(user, workflow_type="regulatory_review", latest=True) + + assert result["ok"] + assert result["batch_no"] == "RR-001" + assert "高风险 1" in result["answer_summary"] + + +def test_query_denies_other_users_batch(django_user_model): + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + conversation = Conversation.objects.create(user=owner, title="会话") + batch = FileSummaryBatch.objects.create(conversation=conversation, user=owner, batch_no="FS-PRIVATE") + + result = query_batch_summary(other, batch_no=batch.batch_no) + + assert not result["ok"] + assert result["permission_result"] == "denied" + + +def test_query_admin_can_access_other_users_batch(django_user_model): + owner = django_user_model.objects.create_user(username="owner", password="pass") + admin = django_user_model.objects.create_user(username="admin", password="pass", is_staff=True) + conversation = Conversation.objects.create(user=owner, title="会话") + FileSummaryBatch.objects.create(conversation=conversation, user=owner, batch_no="FS-ADMIN") + + result = query_batch_summary(admin, batch_no="FS-ADMIN") + + assert result["ok"] + assert result["permission_result"] == "allowed" + + +def test_parse_question_intent_recognizes_batch_latest_and_workflow(): + parsed = parse_question_intent("查最新法规核查") + assert parsed["workflow_type"] == "regulatory_review" + assert parsed["latest"] is True + + parsed = parse_question_intent("AFF-20260607-001 的 Word 在哪里") + assert parsed["workflow_type"] == "application_form_fill" + assert parsed["batch_no"] == "AFF-20260607-001" + assert parsed["intent"] == "export_summary" + + +def test_answer_question_records_log_without_full_answer(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-LOG") + + result = answer_question(user, "查最新自动汇总") + + log = FeishuQuestionLog.objects.get(pk=result["log_id"]) + assert log.intent == "batch_status" + assert log.query_object["workflow_type"] == "file_summary" + assert log.answer_summary + assert len(log.answer_summary) <= 500 + + +def test_feishu_question_simulate_command_outputs_summary(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-CMD") + output = StringIO() + + call_command("feishu_question_simulate", "--username", user.username, "查最新自动汇总", stdout=output) + + assert "FS-CMD" in output.getvalue() diff --git a/tests/test_feishu_workflow_adapters.py b/tests/test_feishu_workflow_adapters.py new file mode 100644 index 0000000..8d915d9 --- /dev/null +++ b/tests/test_feishu_workflow_adapters.py @@ -0,0 +1,96 @@ +import pytest + +from review_agent.models import ( + ApplicationFormFillBatch, + Conversation, + ExportedSummaryFile, + FileSummaryBatch, + RegulatoryIssue, + RegulatoryReviewBatch, +) +from review_agent.notifications.message_builder import absolute_result_url +from review_agent.notifications.workflow_adapters import ( + build_application_form_fill_context, + build_file_summary_context, + build_regulatory_review_context, +) + + +pytestmark = pytest.mark.django_db + + +def test_file_summary_adapter_builds_summary(settings, django_user_model): + settings.PUBLIC_BASE_URL = "http://example.test" + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-001", + status=FileSummaryBatch.Status.SUCCESS, + total_files=3, + success_files=2, + failed_files=1, + total_pages=15, + ) + + context = build_file_summary_context(batch) + + assert context.workflow_type == "file_summary" + assert context.workflow_batch_no == "FS-001" + assert "异常" in "\n".join(context.summary_lines) + assert absolute_result_url(context.result_path).endswith(f"/api/review-agent/file-summary/{batch.pk}/status/") + + +def test_regulatory_review_adapter_builds_risk_summary(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary_batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-RR") + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary_batch, + batch_no="RR-001", + status=RegulatoryReviewBatch.Status.SUCCESS, + ) + RegulatoryIssue.objects.create( + batch=batch, + category=RegulatoryIssue.Category.COMPLETENESS, + severity=RegulatoryIssue.Severity.BLOCKING, + title="缺少资料", + ) + + context = build_regulatory_review_context(batch) + + assert context.workflow_type == "regulatory_review" + assert "阻断项 1" in "\n".join(context.summary_lines) + + +def test_application_form_fill_adapter_builds_export_and_conflict_summary(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary_batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-AFF") + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary_batch, + batch_no="AFF-001", + status=ApplicationFormFillBatch.Status.PARTIAL_SUCCESS, + selected_templates=["registration_certificate"], + conflict_summary=[{"field": "product_name"}], + ) + ExportedSummaryFile.objects.create( + batch=summary_batch, + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + export_category="filled_template", + export_type=ExportedSummaryFile.ExportType.WORD, + file_name="filled.docx", + storage_path="filled.docx", + ) + + context = build_application_form_fill_context(batch) + + assert context.workflow_type == "application_form_fill" + assert "导出文件 1" in "\n".join(context.summary_lines) + assert "冲突字段 1" in "\n".join(context.summary_lines) diff --git a/tests/test_file_summary_archive.py b/tests/test_file_summary_archive.py new file mode 100644 index 0000000..29a1a80 --- /dev/null +++ b/tests/test_file_summary_archive.py @@ -0,0 +1,25 @@ +from zipfile import ZipFile +import pytest + +from review_agent.file_summary.services.archive import extract_archive + + +def test_extract_zip_preserves_safe_paths(tmp_path): + archive_path = tmp_path / "safe.zip" + with ZipFile(archive_path, "w") as archive: + archive.writestr("dir/a.txt", "content") + + target = tmp_path / "out" + extracted = extract_archive(archive_path, target) + + assert extracted == [target / "dir" / "a.txt"] + assert (target / "dir" / "a.txt").read_text(encoding="utf-8") == "content" + + +def test_extract_zip_rejects_path_traversal(tmp_path): + archive_path = tmp_path / "evil.zip" + with ZipFile(archive_path, "w") as archive: + archive.writestr("../evil.txt", "bad") + + with pytest.raises(ValueError): + extract_archive(archive_path, tmp_path / "out") diff --git a/tests/test_file_summary_e2e.py b/tests/test_file_summary_e2e.py new file mode 100644 index 0000000..99baddb --- /dev/null +++ b/tests/test_file_summary_e2e.py @@ -0,0 +1,60 @@ +from pathlib import Path + +import pytest + +from review_agent.models import Conversation, Message + + +pytestmark = pytest.mark.django_db + + +def _browser_path() -> str | None: + candidates = [ + Path(r"C:\Program Files\Google\Chrome\Application\chrome.exe"), + Path(r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"), + ] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + return None + + +def test_file_summary_panel_desktop_and_mobile_with_playwright(live_server, django_user_model): + playwright_api = pytest.importorskip("playwright.sync_api") + executable_path = _browser_path() + if not executable_path: + pytest.skip("No Chrome or Edge executable available for Playwright E2E.") + + user = django_user_model.objects.create_user(username="e2e_user", password="e2e-pass-123") + conversation = Conversation.objects.create(user=user, title="E2E 会话") + Message.objects.create( + conversation=conversation, + role=Message.Role.ASSISTANT, + content=( + "文件目录与页数汇总已完成。\n\n" + "| 序号 | 文件名 | 页数 | 状态 |\n" + "| --- | --- | --- | --- |\n" + "| 1 | a.pdf | 4 | success |\n\n" + "[下载 Markdown 报告](/api/review-agent/file-summary/exports/1/download/)" + ), + ) + + with playwright_api.sync_playwright() as p: + browser = p.chromium.launch(headless=True, executable_path=executable_path) + page = browser.new_page(viewport={"width": 1440, "height": 900}) + page.goto(f"{live_server.url}/login/") + page.fill('input[name="username"]', "e2e_user") + page.fill('input[name="password"]', "e2e-pass-123") + page.click('button[type="submit"]') + page.wait_for_url(f"{live_server.url}/") + + playwright_api.expect(page.locator("#summaryPanel")).to_be_visible() + playwright_api.expect(page.locator("#uploadDropzone")).to_be_visible() + playwright_api.expect(page.locator("#workflowCardList")).to_be_visible() + playwright_api.expect(page.locator(".message.assistant table")).to_be_visible() + playwright_api.expect(page.locator('.message.assistant a[href="/api/review-agent/file-summary/exports/1/download/"]')).to_be_visible() + + page.set_viewport_size({"width": 390, "height": 844}) + playwright_api.expect(page.locator("#summaryPanel")).to_be_visible() + playwright_api.expect(page.locator("#sidebar")).not_to_be_in_viewport() + browser.close() diff --git a/tests/test_file_summary_frontend.py b/tests/test_file_summary_frontend.py new file mode 100644 index 0000000..15289ac --- /dev/null +++ b/tests/test_file_summary_frontend.py @@ -0,0 +1,282 @@ +import pytest +from django.urls import reverse + +from review_agent.models import Conversation, FileAttachment, FileSummaryBatch, Message, WorkflowNodeRun, WorkflowNotificationRecord + + +pytestmark = pytest.mark.django_db + + +def test_workspace_renders_summary_panel(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + Message.objects.create( + conversation=conversation, + role=Message.Role.ASSISTANT, + content="| 序号 | 文件名 |\n| --- | --- |\n| 1 | a.pdf |\n\n[下载](/api/review-agent/file-summary/exports/1/download/)", + ) + client.force_login(user) + + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") + + assert response.status_code == 200 + content = response.content.decode("utf-8") + assert 'id="summaryPanel"' in content + assert 'id="uploadDropzone"' in content + assert 'id="workflowCardList"' in content + assert 'data-conversation-id="' in content + assert 'data-message-id="' in content + assert 'data-message-url-template="' in content + assert 'class="message-content markdown-content"' in content + assert 'class="message-raw"' in content + assert "自动汇总文件目录与页数" in content + + +def test_workspace_links_to_attachment_manager(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + client.force_login(user) + + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") + + assert response.status_code == 200 + content = response.content.decode("utf-8") + assert "附件管理" in content + assert "视频实时监测" not in content + assert f'href="{reverse("attachment_manager")}?conversation={conversation.pk}"' in content + assert 'class="attachment-manager-link"' in content + + +def test_attachment_manager_requires_conversation_selection(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + Conversation.objects.create(user=user, title="待选择会话") + client.force_login(user) + + response = client.get(reverse("attachment_manager")) + + assert response.status_code == 200 + content = response.content.decode("utf-8") + assert "附件管理" in content + assert "请选择一个对话查看附件" in content + assert "待选择会话" in content + assert 'id="attachmentConversationSelect"' in content + + +def test_attachment_manager_selects_conversation_and_lists_attachments(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="资料会话") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="a.docx", + storage_path="x/a.docx", + file_size=128, + is_active=True, + ) + client.force_login(user) + + response = client.get(f"{reverse('attachment_manager')}?conversation={conversation.pk}") + + assert response.status_code == 200 + content = response.content.decode("utf-8") + assert "资料会话" in content + assert "a.docx" in content + assert "下载" in content + assert "编辑" in content + assert "删除" in content + assert "attachment-manager-split" in content + assert reverse("chat") + f"?conversation={conversation.pk}" in content + + +def test_attachment_manager_uses_compact_admin_layout(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + Conversation.objects.create(user=user, title="紧凑会话") + client.force_login(user) + + response = client.get(reverse("attachment_manager")) + + assert response.status_code == 200 + content = response.content.decode("utf-8") + css = open("static/css/login.css", encoding="utf-8").read() + assert "attachment-manager-toolbar" in content + assert "attachment-manager-content" in content + assert "attachment-manager-select-control" in content + assert ".attachment-manager-page" in css + assert "align-content: start" in css + assert ".attachment-manager-toolbar" in css + assert ".attachment-manager-select-control" in css + assert ".attachment-manager-split" in css + + +def test_workspace_renders_workflow_history_as_batch_carousel(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + older = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OLDER", + status=FileSummaryBatch.Status.SUCCESS, + ) + latest = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-LATEST", + status=FileSummaryBatch.Status.FAILED, + error_message="解压失败", + ) + WorkflowNodeRun.objects.create( + batch=older, + node_code="upload", + node_name="附件固化", + status=WorkflowNodeRun.Status.SUCCESS, + progress=100, + message="附件固化完成", + ) + WorkflowNodeRun.objects.create( + batch=latest, + node_code="extract", + node_name="压缩包解压", + status=WorkflowNodeRun.Status.FAILED, + progress=10, + message="压缩包损坏", + ) + client.force_login(user) + + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") + + assert response.status_code == 200 + content = response.content.decode("utf-8") + assert "workflow-batch-carousel" in content + assert 'class="workflow-card active"' in content + assert 'data-workflow-index="0"' in content + assert 'data-workflow-action="prev"' in content + assert 'data-workflow-action="next"' in content + assert content.index("FS-LATEST") < content.index("FS-OLDER") + assert "压缩包损坏" in content + + +def test_frontend_prevents_long_message_overflow(): + css = open("static/css/login.css", encoding="utf-8").read() + + assert ".message-bubble" in css + assert "overflow-wrap: anywhere" in css + assert "word-break: break-word" in css + + +def test_frontend_polls_running_workflow_cards(): + script = open("static/js/app.js", encoding="utf-8").read() + + assert "startWorkflowPolling" in script + assert "setInterval" in script + assert "refreshRunningWorkflowCards" in script + + +def test_frontend_updates_sidebar_conversation_by_stable_id(): + script = open("static/js/app.js", encoding="utf-8").read() + + assert "data-conversation-id" in script + assert "setAttribute(\"data-conversation-id\"" in script + assert ".history-item[data-conversation-id=" in script + + +def test_frontend_refreshes_generated_workflow_messages(): + script = open("static/js/app.js", encoding="utf-8").read() + + assert "refreshConversationMessages" in script + assert "latestMessageId" in script + assert "data-message-url-template" in script + + +def test_frontend_only_scrolls_after_appending_new_messages(): + script = open("static/js/app.js", encoding="utf-8").read() + + assert "return false;" in script + assert "return true;" in script + assert "var appendedCount = 0;" in script + assert "if (appendConversationMessage(message))" in script + assert "if (appendedCount > 0)" in script + + +def test_frontend_can_replace_partial_stream_content(): + script = open("static/js/app.js", encoding="utf-8").read() + + assert 'eventName === "replace"' in script + assert "assistantText = payload.content" in script + + +def test_frontend_enter_sends_and_ctrl_enter_inserts_newline(): + script = open("static/js/app.js", encoding="utf-8").read() + + assert "bindPromptKeyboardShortcuts" in script + assert "event.key === \"Enter\"" in script + assert "event.ctrlKey" in script + assert "composer.requestSubmit()" in script + + +def test_frontend_renders_workflow_error_messages(): + script = open("static/js/app.js", encoding="utf-8").read() + css = open("static/css/login.css", encoding="utf-8").read() + + assert "payload.batch.error_message" in script + assert "workflow-error" in script + assert "node.message" in script + assert ".workflow-error" in css + + +def test_file_summary_status_includes_feishu_notification(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-FEISHU") + WorkflowNotificationRecord.objects.create( + workflow_type="file_summary", + workflow_batch_id=batch.pk, + workflow_batch_no=batch.batch_no, + workflow_status=batch.status, + dedupe_key=f"file_summary:{batch.pk}:{batch.status}", + trigger_user=user, + channel=WorkflowNotificationRecord.Channel.FEISHU_API, + target="负责人", + send_status=WorkflowNotificationRecord.SendStatus.SUCCESS, + message_title="自动汇总完成", + ) + client.force_login(user) + + response = client.get(f"/api/review-agent/file-summary/{batch.pk}/status/") + + payload = response.json() + assert payload["latest_notification"]["status_label"] == "飞书通知已发送" + assert payload["notifications"][0]["target"] == "负责人" + + +def test_frontend_renders_workflow_batches_as_carousel(): + script = open("static/js/app.js", encoding="utf-8").read() + css = open("static/css/login.css", encoding="utf-8").read() + + assert "selectWorkflowBatchIndex" in script + assert "refreshWorkflowBatchCarousel" in script + assert "data-workflow-action" in script + assert "workflow-batch-carousel" in script + assert ".workflow-batch-controls" in css + assert ".workflow-card.active" in css + + +def test_workspace_tool_buttons_fill_default_prompts(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + client.force_login(user) + + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") + + content = response.content.decode("utf-8") + script = open("static/js/app.js", encoding="utf-8").read() + assert "目录自动汇总" in content + assert "法规核查与风险预警" in content + assert "申报文件填表" in content + assert "说明书审查" not in content + assert ">风险预警" not in content + assert 'data-prompt-template="请对当前对话已上传的文件或压缩包自动汇总文件目录' in content + assert 'data-prompt-template="请对当前对话最近成功汇总的注册资料发起 NMPA 法规核查与风险预警' in content + assert 'data-prompt-template="请基于当前对话最近成功汇总的产品资料,自动提取产品关键信息并填入申报文件模板"' in content + assert "优先生成注册证 Word 和字段来源追溯清单" not in content + assert "bindPromptTemplateButtons" in script + assert "promptInput.value = template" in script diff --git a/tests/test_file_summary_inventory.py b/tests/test_file_summary_inventory.py new file mode 100644 index 0000000..74758a5 --- /dev/null +++ b/tests/test_file_summary_inventory.py @@ -0,0 +1,24 @@ +from pathlib import Path +import pytest + +from review_agent.file_summary.services.inventory import scan_files_to_items +from review_agent.models import Conversation, FileSummaryBatch, FileSummaryItem + + +pytestmark = pytest.mark.django_db + + +def test_scan_files_to_items_preserves_relative_paths(tmp_path, django_user_model): + root = tmp_path / "work" + (root / "a").mkdir(parents=True) + (root / "a" / "one.pdf").write_bytes(b"pdf") + (root / "two.txt").write_text("x", encoding="utf-8") + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-I") + + items = scan_files_to_items(batch=batch, roots=[root]) + + assert [item.relative_path for item in items] == ["a/one.pdf", "two.txt"] + assert FileSummaryItem.objects.filter(batch=batch).count() == 2 + assert items[0].statistics_status == FileSummaryItem.StatisticsStatus.SKIPPED diff --git a/tests/test_file_summary_models.py b/tests/test_file_summary_models.py new file mode 100644 index 0000000..52ea6d0 --- /dev/null +++ b/tests/test_file_summary_models.py @@ -0,0 +1,113 @@ +import pytest +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction + +from review_agent.models import ( + Conversation, + ExportedSummaryFile, + FileAttachment, + FileSummaryBatch, + FileSummaryBatchAttachment, + FileSummaryItem, +) + + +pytestmark = pytest.mark.django_db + + +def create_user(username="u1"): + return get_user_model().objects.create_user(username=username, password="pass") + + +def test_attachment_versions_are_unique_per_conversation_and_name(): + user = create_user() + conversation = Conversation.objects.create(user=user, title="会话") + + first = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="资料.docx", + version_no=1, + is_active=False, + storage_path="media/a.docx", + file_size=10, + ) + second = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="资料.docx", + version_no=2, + storage_path="media/b.docx", + file_size=12, + ) + + assert first.version_no == 1 + assert second.version_no == 2 + + with pytest.raises(IntegrityError), transaction.atomic(): + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="资料.docx", + version_no=2, + storage_path="media/c.docx", + file_size=14, + ) + + +def test_batch_attachment_and_item_unique_constraints(): + user = create_user() + conversation = Conversation.objects.create(user=user, title="会话") + attachment = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="资料.docx", + storage_path="media/a.docx", + file_size=10, + ) + batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-001", + ) + + FileSummaryBatchAttachment.objects.create(batch=batch, attachment=attachment) + with pytest.raises(IntegrityError), transaction.atomic(): + FileSummaryBatchAttachment.objects.create(batch=batch, attachment=attachment) + + FileSummaryItem.objects.create( + batch=batch, + file_index=1, + file_name="资料.docx", + file_type="docx", + relative_path="资料.docx", + storage_path="media/a.docx", + ) + with pytest.raises(IntegrityError), transaction.atomic(): + FileSummaryItem.objects.create( + batch=batch, + file_index=2, + file_name="资料.docx", + file_type="docx", + relative_path="资料.docx", + storage_path="media/a.docx", + ) + + +def test_exported_file_traces_to_user_and_conversation(): + user = create_user() + conversation = Conversation.objects.create(user=user, title="会话") + batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-002", + ) + exported = ExportedSummaryFile.objects.create( + batch=batch, + export_type=ExportedSummaryFile.ExportType.MARKDOWN, + file_name="summary.md", + storage_path="media/summary.md", + ) + + assert exported.batch.user == user + assert exported.batch.conversation == conversation diff --git a/tests/test_file_summary_page_count.py b/tests/test_file_summary_page_count.py new file mode 100644 index 0000000..1ce353e --- /dev/null +++ b/tests/test_file_summary_page_count.py @@ -0,0 +1,180 @@ +import pytest +import shutil +from zipfile import ZipFile +from docx import Document +from openpyxl import Workbook +from pptx import Presentation + +from review_agent.file_summary.services.page_count import count_document_pages +from review_agent.file_summary.skills.document_page_count import DocumentPageCountSkill +from review_agent.file_summary.skills.base import WorkflowContext +from review_agent.models import Conversation, FileSummaryBatch, FileSummaryItem + + +pytestmark = pytest.mark.django_db + + +def test_count_document_pages_for_office_formats(tmp_path): + docx_path = tmp_path / "a.docx" + Document().save(docx_path) + + xlsx_path = tmp_path / "a.xlsx" + workbook = Workbook() + workbook.create_sheet("第二页") + workbook.save(xlsx_path) + + pptx_path = tmp_path / "a.pptx" + presentation = Presentation() + presentation.slides.add_slide(presentation.slide_layouts[6]) + presentation.save(pptx_path) + + assert count_document_pages(docx_path).status in {"success", "uncertain"} + assert count_document_pages(xlsx_path).page_count == 2 + assert count_document_pages(pptx_path).page_count == 1 + + +def test_count_docx_pages_from_extended_properties(tmp_path): + docx_path = tmp_path / "with-pages.docx" + Document().save(docx_path) + app_xml = ( + '' + '' + "7" + "" + ) + rewritten = tmp_path / "rewritten.docx" + with ZipFile(docx_path) as source, ZipFile(rewritten, "w") as target: + for entry in source.infolist(): + if entry.filename != "docProps/app.xml": + target.writestr(entry, source.read(entry.filename)) + target.writestr("docProps/app.xml", app_xml) + shutil.move(rewritten, docx_path) + + result = count_document_pages(docx_path) + + assert result.status == "success" + assert result.page_count == 7 + + +def test_count_docx_pages_uses_word_com_fallback(monkeypatch, tmp_path): + docx_path = tmp_path / "without-pages.docx" + Document().save(docx_path) + monkeypatch.setattr( + "review_agent.file_summary.services.page_count._count_docx_pages_from_extended_properties", + lambda path: None, + ) + monkeypatch.setattr( + "review_agent.file_summary.services.page_count._count_word_pages_with_com", + lambda path: 22, + ) + + result = count_document_pages(docx_path) + + assert result.status == "success" + assert result.page_count == 22 + + +def test_count_doc_pages_uses_word_com_fallback(monkeypatch, tmp_path): + doc_path = tmp_path / "legacy.doc" + doc_path.write_bytes(b"legacy-doc-placeholder") + monkeypatch.setattr( + "review_agent.file_summary.services.page_count._can_try_com_fallback", + lambda path, ext: True, + ) + monkeypatch.setattr( + "review_agent.file_summary.services.page_count._count_word_pages_with_com", + lambda path: 5, + ) + + result = count_document_pages(doc_path) + + assert result.status == "success" + assert result.page_count == 5 + + +def test_count_ppt_pages_uses_powerpoint_com_fallback(monkeypatch, tmp_path): + ppt_path = tmp_path / "legacy.ppt" + ppt_path.write_bytes(b"legacy-ppt-placeholder") + monkeypatch.setattr( + "review_agent.file_summary.services.page_count._can_try_com_fallback", + lambda path, ext: True, + ) + monkeypatch.setattr( + "review_agent.file_summary.services.page_count._count_powerpoint_slides_with_com", + lambda path: 9, + ) + + result = count_document_pages(ppt_path) + + assert result.status == "success" + assert result.page_count == 9 + + +def test_count_excel_pages_uses_excel_com_fallback(monkeypatch, tmp_path): + xls_path = tmp_path / "legacy.xls" + xls_path.write_bytes(b"legacy-xls-placeholder") + monkeypatch.setattr( + "review_agent.file_summary.services.page_count._can_try_com_fallback", + lambda path, ext: True, + ) + monkeypatch.setattr( + "review_agent.file_summary.services.page_count._count_excel_sheets_with_com", + lambda path: 3, + ) + + result = count_document_pages(xls_path) + + assert result.status == "success" + assert result.page_count == 3 + + +def test_invalid_xlsx_does_not_start_excel_com(monkeypatch, tmp_path): + xlsx_path = tmp_path / "broken.xlsx" + xlsx_path.write_bytes(b"not a real workbook") + + def fail_if_called(path): + raise AssertionError("Excel COM should not start for invalid xlsx signatures") + + monkeypatch.setattr( + "review_agent.file_summary.services.page_count._count_excel_sheets_with_com", + fail_if_called, + ) + + result = count_document_pages(xlsx_path) + + assert result.status == "uncertain" + + +def test_document_page_count_skill_marks_unsupported_and_success(tmp_path, django_user_model): + xlsx_path = tmp_path / "a.xlsx" + workbook = Workbook() + workbook.save(xlsx_path) + txt_path = tmp_path / "a.txt" + txt_path.write_text("x", encoding="utf-8") + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-P") + xlsx_item = FileSummaryItem.objects.create( + batch=batch, + file_index=1, + file_name="a.xlsx", + file_type="xlsx", + relative_path="a.xlsx", + storage_path=str(xlsx_path), + ) + txt_item = FileSummaryItem.objects.create( + batch=batch, + file_index=2, + file_name="a.txt", + file_type="txt", + relative_path="a.txt", + storage_path=str(txt_path), + ) + + result = DocumentPageCountSkill().run(WorkflowContext(batch=batch)) + + xlsx_item.refresh_from_db() + txt_item.refresh_from_db() + assert result.success is True + assert xlsx_item.statistics_status == FileSummaryItem.StatisticsStatus.SUCCESS + assert txt_item.statistics_status == FileSummaryItem.StatisticsStatus.UNSUPPORTED diff --git a/tests/test_file_summary_product_detect.py b/tests/test_file_summary_product_detect.py new file mode 100644 index 0000000..8cf895c --- /dev/null +++ b/tests/test_file_summary_product_detect.py @@ -0,0 +1,29 @@ +import pytest + +from review_agent.file_summary.services.product_detect import detect_product_name +from review_agent.models import Conversation, FileSummaryBatch, FileSummaryItem + + +pytestmark = pytest.mark.django_db + + +def test_detect_product_name_from_top_level_directory(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="新对话 06-06") + batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-D") + FileSummaryItem.objects.create( + batch=batch, + file_index=1, + file_name="说明书.docx", + file_type="docx", + relative_path="甲型试剂盒/说明书.docx", + storage_path="x", + ) + + product_name = detect_product_name(batch) + + batch.refresh_from_db() + conversation.refresh_from_db() + assert product_name == "甲型试剂盒" + assert batch.product_name == "甲型试剂盒" + assert conversation.title == "甲型试剂盒-文件汇总" diff --git a/tests/test_file_summary_report.py b/tests/test_file_summary_report.py new file mode 100644 index 0000000..aecc240 --- /dev/null +++ b/tests/test_file_summary_report.py @@ -0,0 +1,82 @@ +from pathlib import Path +import pytest +from openpyxl import load_workbook + +from review_agent.file_summary.services.export_excel import generate_excel_export +from review_agent.file_summary.services.report import generate_markdown_report +from review_agent.models import Conversation, FileSummaryBatch, FileSummaryItem, Message + + +pytestmark = pytest.mark.django_db + + +def make_batch(tmp_path, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-R", + work_dir=str(tmp_path), + total_files=1, + success_files=1, + total_pages=2, + ) + FileSummaryItem.objects.create( + batch=batch, + file_index=1, + file_name="a.xlsx", + file_type="xlsx", + relative_path="a.xlsx", + storage_path=str(tmp_path / "a.xlsx"), + page_count=2, + statistics_status=FileSummaryItem.StatisticsStatus.SUCCESS, + ) + return batch + + +def test_generate_markdown_report_creates_export_and_summary(tmp_path, django_user_model): + batch = make_batch(tmp_path, django_user_model) + + exported, summary = generate_markdown_report(batch) + + assert exported.export_type == "markdown" + assert Path(exported.storage_path).exists() + assert "| 序号 | 目录层级 | 文件名 | 类型 | 页数 | 状态 | 异常说明 |" in summary + assert "a.xlsx" in Path(exported.storage_path).read_text(encoding="utf-8") + + +def test_generate_excel_export_contains_summary_and_items(tmp_path, django_user_model): + batch = make_batch(tmp_path, django_user_model) + + exported = generate_excel_export(batch) + + workbook = load_workbook(exported.storage_path) + assert workbook.sheetnames == ["汇总信息", "文件明细"] + assert workbook["文件明细"]["C2"].value == "a.xlsx" + + +def test_workflow_report_node_writes_assistant_message(tmp_path, settings, django_user_model): + from review_agent.file_summary.workflow import create_file_summary_batch, start_file_summary_workflow + from review_agent.models import FileAttachment + + settings.MEDIA_ROOT = tmp_path + settings.FILE_SUMMARY_ASYNC = False + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + file_path = tmp_path / "a.xlsx" + file_path.write_bytes(b"not a real workbook") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="a.txt", + storage_path=str(file_path), + file_size=file_path.stat().st_size, + ) + batch = create_file_summary_batch(conversation=conversation, user=user) + batch.work_dir = str(tmp_path / "batch") + batch.save(update_fields=["work_dir"]) + + start_file_summary_workflow(batch, async_run=False) + + assert Message.objects.filter(conversation=conversation, role=Message.Role.ASSISTANT).exists() diff --git a/tests/test_file_summary_skills.py b/tests/test_file_summary_skills.py new file mode 100644 index 0000000..ba3daf1 --- /dev/null +++ b/tests/test_file_summary_skills.py @@ -0,0 +1,46 @@ +import pytest +import logging + +from review_agent.file_summary.skills.base import BaseSkill, SkillResult, WorkflowContext +from review_agent.file_summary.skills.registry import SkillRegistry + + +class EchoSkill(BaseSkill): + name = "echo" + + def run(self, context): + return SkillResult(success=True, data={"batch_id": context.batch.id}) + + +@pytest.mark.django_db +def test_skill_registry_executes_registered_skill(django_user_model): + from review_agent.models import Conversation, FileSummaryBatch + + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-X") + registry = SkillRegistry() + registry.register(EchoSkill()) + + result = registry.execute("echo", WorkflowContext(batch=batch)) + + assert result.success is True + assert result.data == {"batch_id": batch.id} + + +@pytest.mark.django_db +def test_skill_registry_logs_skill_lifecycle(caplog, django_user_model): + from review_agent.models import Conversation, FileSummaryBatch + + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-LOG") + registry = SkillRegistry() + registry.register(EchoSkill()) + + with caplog.at_level(logging.INFO, logger="review_agent.file_summary"): + registry.execute("echo", WorkflowContext(batch=batch)) + + messages = [record.getMessage() for record in caplog.records] + assert any("Skill started" in message and "echo" in message for message in messages) + assert any("Skill finished" in message and "echo" in message for message in messages) diff --git a/tests/test_file_summary_storage.py b/tests/test_file_summary_storage.py new file mode 100644 index 0000000..38220b6 --- /dev/null +++ b/tests/test_file_summary_storage.py @@ -0,0 +1,48 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +import pytest + +from review_agent.file_summary.storage import save_uploaded_attachment +from review_agent.models import Conversation, FileAttachment + + +pytestmark = pytest.mark.django_db + + +def test_save_uploaded_attachment_versions_same_name(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + first = save_uploaded_attachment( + conversation=conversation, + user=user, + uploaded_file=SimpleUploadedFile("资料.docx", b"first"), + ) + second = save_uploaded_attachment( + conversation=conversation, + user=user, + uploaded_file=SimpleUploadedFile("资料.docx", b"second"), + ) + + first.refresh_from_db() + assert first.version_no == 1 + assert first.is_active is False + assert second.version_no == 2 + assert second.is_active is True + assert FileAttachment.objects.filter(conversation=conversation).count() == 2 + assert (tmp_path / second.storage_path).read_bytes() == b"second" + + +def test_save_uploaded_attachment_rejects_path_traversal(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + attachment = save_uploaded_attachment( + conversation=conversation, + user=user, + uploaded_file=SimpleUploadedFile("../资料.docx", b"content"), + ) + + assert ".." not in attachment.storage_path + assert (tmp_path / attachment.storage_path).exists() diff --git a/tests/test_file_summary_trigger.py b/tests/test_file_summary_trigger.py new file mode 100644 index 0000000..ad0c8c3 --- /dev/null +++ b/tests/test_file_summary_trigger.py @@ -0,0 +1,73 @@ +import pytest + +from review_agent.file_summary.workflow_trigger import ( + evaluate_attachment_reader_trigger, + evaluate_file_summary_trigger, +) +from review_agent.models import Conversation, FileAttachment + + +pytestmark = pytest.mark.django_db + + +def test_trigger_matches_keywords_only_when_active_attachment_exists(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + no_file = evaluate_file_summary_trigger(conversation, "请自动汇总文件目录与页数") + assert no_file.should_start is False + assert no_file.reason == "missing_attachment" + + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="a.docx", + storage_path="x/a.docx", + file_size=1, + ) + + matched = evaluate_file_summary_trigger(conversation, "请自动汇总文件目录与页数") + assert matched.should_start is True + assert matched.workflow_type == "file_summary" + + normal = evaluate_file_summary_trigger(conversation, "你好,帮我解释法规") + assert normal.should_start is False + assert normal.reason == "not_matched" + + +def test_attachment_reader_trigger_matches_file_content_phrases(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + missing = evaluate_attachment_reader_trigger(conversation, "根据提供的简历文件内容,简要介绍") + assert missing.should_start is False + assert missing.reason == "missing_attachment" + + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="resume.docx", + storage_path="x/resume.docx", + file_size=1, + ) + + matched = evaluate_attachment_reader_trigger(conversation, "根据提供的简历文件内容,简要介绍") + assert matched.should_start is True + assert matched.workflow_type == "attachment_reader" + + +def test_attachment_reader_trigger_matches_resume_project_experience_request(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="resume.docx", + storage_path="x/resume.docx", + file_size=1, + ) + + matched = evaluate_attachment_reader_trigger(conversation, "阅读下附件简历中的项目经历") + + assert matched.should_start is True + assert matched.workflow_type == "attachment_reader" diff --git a/tests/test_file_summary_views.py b/tests/test_file_summary_views.py new file mode 100644 index 0000000..c4a8a83 --- /dev/null +++ b/tests/test_file_summary_views.py @@ -0,0 +1,370 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +import json +import pytest + +from review_agent.models import ( + ApplicationFormFillBatch, + Conversation, + ExportedSummaryFile, + FileAttachment, + FileSummaryBatch, + Message, + RegulatoryReviewBatch, + WorkflowNodeRun, +) + + +pytestmark = pytest.mark.django_db + + +def test_upload_attachments_requires_conversation_owner(client, settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + conversation = Conversation.objects.create(user=owner, title="会话") + client.force_login(other) + + response = client.post( + reverse("file_summary_attachment_upload", args=[conversation.pk]), + {"files": [SimpleUploadedFile("a.docx", b"a")]}, + ) + + assert response.status_code == 404 + + +def test_attachment_api_requires_login(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + response = client.get(reverse("file_summary_attachment_list", args=[conversation.pk])) + + assert response.status_code == 302 + + +def test_upload_and_list_current_conversation_attachments(client, settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + client.force_login(user) + + upload_response = client.post( + reverse("file_summary_attachment_upload", args=[conversation.pk]), + { + "files": [ + SimpleUploadedFile("a.docx", b"a", content_type="application/docx"), + SimpleUploadedFile("b.zip", b"b", content_type="application/zip"), + ] + }, + ) + list_response = client.get(reverse("file_summary_attachment_list", args=[conversation.pk])) + + assert upload_response.status_code == 200 + assert upload_response.json()["attachments"][0]["original_name"] == "a.docx" + assert len(list_response.json()["attachments"]) == 2 + + +def test_delete_attachment_is_logical_and_scoped(client, settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + attachment = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="a.docx", + storage_path="x/a.docx", + file_size=1, + ) + client.force_login(user) + + response = client.delete(reverse("file_summary_attachment_detail", args=[conversation.pk, attachment.pk])) + + attachment.refresh_from_db() + assert response.status_code == 200 + assert attachment.upload_status == FileAttachment.UploadStatus.DELETED + assert attachment.is_active is False + + +def test_export_download_requires_batch_owner(client, tmp_path, django_user_model): + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + conversation = Conversation.objects.create(user=owner, title="会话") + batch = FileSummaryBatch.objects.create(conversation=conversation, user=owner, batch_no="FS-DL") + report_path = tmp_path / "summary.md" + report_path.write_text("ok", encoding="utf-8") + exported = ExportedSummaryFile.objects.create( + batch=batch, + export_type=ExportedSummaryFile.ExportType.MARKDOWN, + file_name="summary.md", + storage_path=str(report_path), + ) + + client.force_login(other) + denied = client.get(reverse("file_summary_export_download", args=[exported.pk])) + assert denied.status_code == 404 + + client.force_login(owner) + allowed = client.get(reverse("file_summary_export_download", args=[exported.pk])) + assert allowed.status_code == 200 + assert "attachment" in allowed["Content-Disposition"] + assert "summary.md" in allowed["Content-Disposition"] + assert allowed["Content-Type"].startswith("text/markdown") + + +def test_export_download_checks_application_form_fill_batch_owner(client, tmp_path, django_user_model): + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + owner_conversation = Conversation.objects.create(user=owner, title="自动填表") + other_conversation = Conversation.objects.create(user=other, title="其他对话") + owner_summary = FileSummaryBatch.objects.create( + conversation=owner_conversation, + user=owner, + batch_no="FS-AFF-OWNER", + status=FileSummaryBatch.Status.SUCCESS, + ) + other_summary = FileSummaryBatch.objects.create( + conversation=other_conversation, + user=other, + batch_no="FS-AFF-OTHER", + status=FileSummaryBatch.Status.SUCCESS, + ) + form_batch = ApplicationFormFillBatch.objects.create( + conversation=owner_conversation, + user=owner, + source_summary_batch=owner_summary, + batch_no="AFF-DL", + ) + report_path = tmp_path / "filled.docx" + report_path.write_bytes(b"word-content") + exported = ExportedSummaryFile.objects.create( + batch=other_summary, + workflow_type="application_form_fill", + workflow_batch_id=form_batch.pk, + export_category="filled_template", + export_type=ExportedSummaryFile.ExportType.WORD, + file_name="filled.docx", + storage_path=str(report_path), + ) + + client.force_login(other) + denied = client.get(reverse("file_summary_export_download", args=[exported.pk])) + assert denied.status_code == 404 + + client.force_login(owner) + allowed = client.get(reverse("file_summary_export_download", args=[exported.pk])) + assert allowed.status_code == 200 + assert allowed["Content-Type"].startswith( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + assert b"".join(allowed.streaming_content) == b"word-content" + + +def test_conversation_messages_returns_incremental_messages(client, django_user_model): + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + conversation = Conversation.objects.create(user=owner, title="会话") + first = Message.objects.create( + conversation=conversation, + role=Message.Role.USER, + content="用户消息", + ) + second = Message.objects.create( + conversation=conversation, + role=Message.Role.ASSISTANT, + content="报告消息", + ) + + client.force_login(other) + denied = client.get(reverse("review_agent_conversation_messages", args=[conversation.pk])) + assert denied.status_code == 404 + + client.force_login(owner) + response = client.get( + f"{reverse('review_agent_conversation_messages', args=[conversation.pk])}?after={first.pk}" + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["latest_message_id"] == second.pk + assert payload["messages"] == [ + { + "id": second.pk, + "role": Message.Role.ASSISTANT, + "content": "报告消息", + "created_at": second.created_at.isoformat(), + } + ] + + +def test_batch_status_exposes_batch_and_node_errors(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-ERR", + status=FileSummaryBatch.Status.FAILED, + error_message="压缩包解压失败", + ) + WorkflowNodeRun.objects.create( + batch=batch, + node_code="extract", + node_name="压缩包解压", + status=WorkflowNodeRun.Status.FAILED, + progress=10, + message="未解出任何可扫描文件", + ) + client.force_login(user) + + response = client.get(reverse("file_summary_batch_status", args=[batch.pk])) + + assert response.status_code == 200 + payload = response.json() + assert payload["batch"]["error_message"] == "压缩包解压失败" + assert payload["nodes"][0]["message"] == "未解出任何可扫描文件" + + +def test_conversation_list_api_returns_owned_conversations_with_attachment_counts(client, django_user_model): + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + owned = Conversation.objects.create(user=owner, title="有附件会话") + Conversation.objects.create(user=other, title="其他用户会话") + FileAttachment.objects.create( + conversation=owned, + user=owner, + original_name="a.docx", + storage_path="x/a.docx", + file_size=1, + ) + FileAttachment.objects.create( + conversation=owned, + user=owner, + original_name="deleted.docx", + storage_path="x/deleted.docx", + file_size=1, + upload_status=FileAttachment.UploadStatus.DELETED, + is_active=False, + ) + client.force_login(owner) + + response = client.get(reverse("review_agent_conversation_list")) + + assert response.status_code == 200 + payload = response.json() + assert [item["title"] for item in payload["conversations"]] == ["有附件会话"] + assert payload["conversations"][0]["attachment_count"] == 1 + + +def test_conversation_delete_api_removes_owned_conversation(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + owned = Conversation.objects.create(user=user, title="待删除") + other_conversation = Conversation.objects.create(user=other, title="别人的会话") + client.force_login(user) + + response = client.delete(reverse("review_agent_conversation_detail", args=[owned.pk])) + + assert response.status_code == 200 + assert response.json()["ok"] is True + assert not Conversation.objects.filter(pk=owned.pk).exists() + assert Conversation.objects.filter(pk=other_conversation.pk).exists() + + +def test_conversation_delete_api_removes_protected_workflow_dependents(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="待删除") + summary_batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-DELETE-PROTECTED", + ) + regulatory_batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary_batch, + batch_no="RR-DELETE-PROTECTED", + ) + form_batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary_batch, + source_regulatory_batch=regulatory_batch, + batch_no="AFF-DELETE-PROTECTED", + ) + client.force_login(user) + + response = client.delete(reverse("review_agent_conversation_detail", args=[conversation.pk])) + + assert response.status_code == 200 + assert response.json()["ok"] is True + assert not Conversation.objects.filter(pk=conversation.pk).exists() + assert not FileSummaryBatch.objects.filter(pk=summary_batch.pk).exists() + assert not RegulatoryReviewBatch.objects.filter(pk=regulatory_batch.pk).exists() + assert not ApplicationFormFillBatch.objects.filter(pk=form_batch.pk).exists() + + +def test_conversation_delete_api_rejects_unowned_conversation(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + other_conversation = Conversation.objects.create(user=other, title="别人的会话") + client.force_login(user) + + response = client.delete(reverse("review_agent_conversation_detail", args=[other_conversation.pk])) + + assert response.status_code == 404 + assert Conversation.objects.filter(pk=other_conversation.pk).exists() + + +def test_patch_attachment_updates_name_and_active_state(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + attachment = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="old.docx", + storage_path="x/old.docx", + file_size=1, + is_active=True, + ) + client.force_login(user) + + response = client.patch( + reverse("file_summary_attachment_detail", args=[conversation.pk, attachment.pk]), + data=json.dumps({"original_name": "new.docx", "is_active": False}), + content_type="application/json", + ) + + attachment.refresh_from_db() + assert response.status_code == 200 + assert attachment.original_name == "new.docx" + assert attachment.is_active is False + assert response.json()["attachment"]["original_name"] == "new.docx" + + +def test_attachment_download_requires_owner_and_returns_file(client, settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + conversation = Conversation.objects.create(user=owner, title="会话") + attachment_path = tmp_path / "uploads" / "a.docx" + attachment_path.parent.mkdir(parents=True) + attachment_path.write_bytes(b"attachment-content") + attachment = FileAttachment.objects.create( + conversation=conversation, + user=owner, + original_name="a.docx", + storage_path=str(attachment_path), + file_size=attachment_path.stat().st_size, + content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + + client.force_login(other) + denied = client.get(reverse("file_summary_attachment_download", args=[conversation.pk, attachment.pk])) + assert denied.status_code == 404 + + client.force_login(owner) + allowed = client.get(reverse("file_summary_attachment_download", args=[conversation.pk, attachment.pk])) + assert allowed.status_code == 200 + assert "attachment" in allowed["Content-Disposition"] + assert "a.docx" in allowed["Content-Disposition"] + assert b"".join(allowed.streaming_content) == b"attachment-content" diff --git a/tests/test_file_summary_workflow.py b/tests/test_file_summary_workflow.py new file mode 100644 index 0000000..8db3ac1 --- /dev/null +++ b/tests/test_file_summary_workflow.py @@ -0,0 +1,366 @@ +import pytest +from pathlib import Path +from zipfile import ZipFile + +from review_agent.file_summary.services import archive as archive_service +from review_agent.file_summary.workflow import create_file_summary_batch, start_file_summary_workflow +from review_agent.skill_router import SkillRoute +from review_agent.models import ( + Conversation, + FileAttachment, + FileSummaryBatch, + FileSummaryBatchAttachment, + Message, + WorkflowEvent, + WorkflowNodeRun, +) +from review_agent.services import stream_message + + +pytestmark = pytest.mark.django_db + + +def test_create_batch_binds_active_attachments_and_initializes_nodes(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + message = Message.objects.create(conversation=conversation, role=Message.Role.USER, content="自动汇总") + active = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="a.docx", + storage_path="x/a.docx", + file_size=1, + ) + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="old.docx", + is_active=False, + storage_path="x/old.docx", + file_size=1, + ) + + batch = create_file_summary_batch(conversation=conversation, user=user, trigger_message=message) + + assert batch.status == FileSummaryBatch.Status.PENDING + assert FileSummaryBatchAttachment.objects.get(batch=batch).attachment == active + active.refresh_from_db() + assert active.upload_status == FileAttachment.UploadStatus.BOUND + assert batch.work_dir + assert WorkflowNodeRun.objects.filter(batch=batch).count() >= 6 + assert WorkflowEvent.objects.filter(batch=batch, event_type="workflow_created").exists() + + +def test_start_file_summary_workflow_runs_synchronously_for_tests(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + message = Message.objects.create(conversation=conversation, role=Message.Role.USER, content="自动汇总") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="a.docx", + storage_path="x/a.docx", + file_size=1, + ) + batch = create_file_summary_batch(conversation=conversation, user=user, trigger_message=message) + + start_file_summary_workflow(batch, async_run=False) + + batch.refresh_from_db() + assert batch.status == FileSummaryBatch.Status.SUCCESS + assert WorkflowEvent.objects.filter(batch=batch, event_type="workflow_completed").exists() + + +def test_file_summary_workflow_dispatches_completion_notification(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="a.docx", + storage_path="x/a.docx", + file_size=1, + ) + batch = create_file_summary_batch(conversation=conversation, user=user) + calls = [] + + def fake_dispatch(context): + calls.append(context) + + monkeypatch.setattr("review_agent.file_summary.workflow.dispatch_workflow_notification", fake_dispatch) + + start_file_summary_workflow(batch, async_run=False) + + assert calls + assert calls[-1].workflow_type == "file_summary" + assert calls[-1].workflow_batch_id == batch.pk + + +def test_workflow_extracts_archive_and_scans_extracted_files(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + archive_path = tmp_path / "upload.zip" + with ZipFile(archive_path, "w") as archive: + archive.writestr("folder/a.pdf", b"%PDF-1.4\n%%EOF") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="upload.zip", + storage_path=str(archive_path), + file_size=archive_path.stat().st_size, + ) + batch = create_file_summary_batch(conversation=conversation, user=user) + + start_file_summary_workflow(batch, async_run=False) + + batch.refresh_from_db() + assert batch.total_files == 1 + assert batch.items.get().file_name == "a.pdf" + assert not batch.items.filter(file_type="zip").exists() + + +def test_workflow_marks_archive_extract_failure_visible(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + archive_path = tmp_path / "empty.zip" + with ZipFile(archive_path, "w"): + pass + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="empty.zip", + storage_path=str(archive_path), + file_size=archive_path.stat().st_size, + ) + batch = create_file_summary_batch(conversation=conversation, user=user) + + start_file_summary_workflow(batch, async_run=False) + + batch.refresh_from_db() + extract_node = batch.node_runs.get(node_code="extract") + assert batch.status == FileSummaryBatch.Status.FAILED + assert "未解出任何可扫描文件" in batch.error_message + assert extract_node.status == WorkflowNodeRun.Status.FAILED + assert "未解出任何可扫描文件" in extract_node.message + failed_event = WorkflowEvent.objects.filter( + batch=batch, + event_type="node_progress", + payload__status=WorkflowNodeRun.Status.FAILED, + ).latest("id") + assert "未解出任何可扫描文件" in failed_event.payload["message"] + + +def test_rar_extract_uses_python_libarchive_before_7z(monkeypatch, tmp_path): + archive_path = tmp_path / "sample.rar" + archive_path.write_bytes(b"rar") + target_dir = tmp_path / "out" + calls = [] + + def fake_libarchive_extract(path: Path, target: Path): + calls.append(("libarchive", path, target)) + extracted = target / "a.docx" + extracted.parent.mkdir(parents=True, exist_ok=True) + extracted.write_bytes(b"doc") + return [extracted] + + def fake_7z_extract(path: Path, target: Path): + calls.append(("7z", path, target)) + return [] + + monkeypatch.setattr(archive_service, "_extract_rar_with_libarchive", fake_libarchive_extract) + monkeypatch.setattr(archive_service, "_extract_rar_with_7z", fake_7z_extract) + + extracted = archive_service.extract_archive(archive_path, target_dir) + + assert [path.name for path in extracted] == ["a.docx"] + assert calls == [("libarchive", archive_path, target_dir)] + + +def test_stream_message_returns_workflow_meta_when_triggered(settings, django_user_model): + settings.FILE_SUMMARY_ASYNC = False + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="a.docx", + storage_path="x/a.docx", + file_size=1, + ) + + frames = list(stream_message(conversation, "请自动汇总文件目录与页数")) + + joined = "".join(frames) + assert "workflow_started" in joined + assert "\"workflow_type\": \"file_summary\"" in joined + assert FileSummaryBatch.objects.filter(conversation=conversation).exists() + + +def test_stream_message_uses_normal_llm_path_when_not_triggered(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + calls = [] + + def fake_stream_reply(conversation, content, knowledge_context=""): + calls.append(knowledge_context) + yield "普通回复" + + monkeypatch.setattr("review_agent.services.stream_reply", fake_stream_reply) + monkeypatch.setattr( + "review_agent.services.search_knowledge_base", + lambda query, n_results=3: { + "query": query, + "results": [ + { + "source": "用户知识库/1/2/孙之烨-260510.pdf", + "text": "孙之烨负责审核智能体项目。", + "score": 0.23, + } + ], + "error_message": "", + }, + ) + + frames = list(stream_message(conversation, "孙之烨是谁")) + + joined = "".join(frames) + assert "普通回复" in joined + assert "workflow_started" not in joined + assert calls + assert "孙之烨负责审核智能体项目" in calls[0] + assert "用户知识库/1/2/孙之烨-260510.pdf" in calls[0] + + +def test_stream_message_meta_uses_first_prompt_title_for_new_conversation(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="新对话 01-01 10:00") + + def fake_stream_reply(conversation, content): + yield "普通回复" + + monkeypatch.setattr("review_agent.services.stream_reply", fake_stream_reply) + + frames = list(stream_message(conversation, "这是第一条新对话消息")) + + assert '"title": "这是第一条新对话消息"' in frames[0] + assert '"title": "这是第一条新对话消息"' in frames[-1] + + +def test_stream_message_reads_active_attachment_when_requested(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + attachment_path = tmp_path / "uploads" / "detail.txt" + attachment_path.parent.mkdir(parents=True) + attachment_path.write_text("合同编号:RA-2026\n结论:附件阅读成功", encoding="utf-8") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="detail.txt", + storage_path="uploads/detail.txt", + file_size=attachment_path.stat().st_size, + ) + + frames = list(stream_message(conversation, "请阅读附件并给出详情")) + + joined = "".join(frames) + assert "附件解析结果" in joined + assert "detail.txt" in joined + assert "RA-2026" in joined + assert "workflow_started" not in joined + + +def test_stream_message_falls_back_to_non_stream_reply_when_stream_breaks(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + def broken_stream_reply(conversation, content, knowledge_context=""): + yield "已生成部分内容" + raise RuntimeError("provider connection reset") + + monkeypatch.setattr("review_agent.services.stream_reply", broken_stream_reply) + monkeypatch.setattr( + "review_agent.services.generate_reply", + lambda conversation, content, knowledge_context="": "非流式完整回复", + ) + + frames = list(stream_message(conversation, "注册检验报告审核要点有哪些")) + + joined = "".join(frames) + assert "已生成部分内容" in joined + assert "replace" in joined + assert "非流式完整回复" in joined + assert "done" in joined + assistant_message = Message.objects.get(conversation=conversation, role=Message.Role.ASSISTANT) + assert assistant_message.content == "非流式完整回复" + + +def test_stream_message_uses_llm_router_for_attachment_reader( + monkeypatch, + settings, + tmp_path, + django_user_model, +): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + attachment_path = tmp_path / "uploads" / "resume.txt" + attachment_path.parent.mkdir(parents=True) + attachment_path.write_text("项目经历:负责审核智能体附件解析模块。", encoding="utf-8") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="resume.txt", + storage_path="uploads/resume.txt", + file_size=attachment_path.stat().st_size, + ) + + monkeypatch.setattr( + "review_agent.services.route_message_intent", + lambda conversation, content: SkillRoute( + action="attachment_reader", + skill_name="attachment_reader", + confidence=0.91, + reason="需要读取上传简历。", + source="llm", + ), + ) + + frames = list(stream_message(conversation, "帮我整理其中的项目经历")) + + joined = "".join(frames) + assert "附件解析结果" in joined + assert "审核智能体附件解析模块" in joined + assert "模型调用失败" not in joined + + +def test_stream_message_uses_llm_router_for_file_summary(monkeypatch, settings, django_user_model): + settings.FILE_SUMMARY_ASYNC = False + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="a.docx", + storage_path="x/a.docx", + file_size=1, + ) + monkeypatch.setattr( + "review_agent.services.route_message_intent", + lambda conversation, content: SkillRoute( + action="file_summary", + workflow_type="file_summary", + confidence=0.93, + reason="需要执行文件目录与页数汇总。", + source="llm", + ), + ) + + frames = list(stream_message(conversation, "处理一下这批资料")) + + joined = "".join(frames) + assert "workflow_started" in joined + assert "\"workflow_type\": \"file_summary\"" in joined + assert FileSummaryBatch.objects.filter(conversation=conversation).exists() diff --git a/tests/test_home_dashboard.py b/tests/test_home_dashboard.py new file mode 100644 index 0000000..499d8e6 --- /dev/null +++ b/tests/test_home_dashboard.py @@ -0,0 +1,146 @@ +import pytest +from django.urls import reverse + +from review_agent.models import ( + ApplicationFormFillBatch, + Conversation, + FileAttachment, + FileSummaryBatch, + KnowledgeBaseDocument, + RegulatoryReviewBatch, +) + + +pytestmark = pytest.mark.django_db + + +def test_home_dashboard_renders_current_user_metrics(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + conversation = Conversation.objects.create(user=user, title="注册资料会话") + other_conversation = Conversation.objects.create(user=other, title="其他用户会话") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="active.docx", + storage_path="x/active.docx", + file_size=128, + is_active=True, + ) + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="deleted.docx", + storage_path="x/deleted.docx", + file_size=128, + is_active=False, + upload_status=FileAttachment.UploadStatus.DELETED, + ) + FileAttachment.objects.create( + conversation=other_conversation, + user=other, + original_name="other.docx", + storage_path="x/other.docx", + file_size=128, + ) + KnowledgeBaseDocument.objects.create( + user=user, + display_name="法规资料", + original_name="rule.md", + storage_path="kb/rule.md", + file_size=64, + is_active=True, + indexed_chunk_count=3, + ) + KnowledgeBaseDocument.objects.create( + user=user, + display_name="删除资料", + original_name="deleted.md", + storage_path="kb/deleted.md", + file_size=64, + status=KnowledgeBaseDocument.Status.DELETED, + is_active=False, + indexed_chunk_count=5, + ) + KnowledgeBaseDocument.objects.create( + user=other, + display_name="其他资料", + original_name="other.md", + storage_path="kb/other.md", + file_size=64, + indexed_chunk_count=9, + ) + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-RUN", + status=FileSummaryBatch.Status.RUNNING, + ) + RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-WAIT", + status=RegulatoryReviewBatch.Status.WAITING_USER, + risk_summary={"high": 2}, + ) + ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="AFF-OK", + status=ApplicationFormFillBatch.Status.SUCCESS, + ) + FileSummaryBatch.objects.create( + conversation=other_conversation, + user=other, + batch_no="FS-OTHER", + status=FileSummaryBatch.Status.FAILED, + ) + client.force_login(user) + + response = client.get(reverse("home")) + + assert response.status_code == 200 + content = response.content.decode("utf-8") + assert "注册资料审核工作台" in content + assert "当前账号资料、知识库、附件与审核处理数据总览" in content + assert "工作流流程" not in content + assert "对话总数" in content + assert "附件总数" in content + assert "知识库材料" in content + assert "内置材料" in content + assert f"管理 {1} · 内置" in content + assert "向量片段" in content + assert "FS-RUN" in content + assert "RR-WAIT" in content + assert "AFF-OK" in content + assert "FS-OTHER" not in content + assert "其他用户会话" not in content + assert f'href="{reverse("chat")}?conversation={conversation.pk}"' in content + + +def test_chat_route_renders_review_agent_workspace(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="审核会话") + client.force_login(user) + + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") + + assert response.status_code == 200 + content = response.content.decode("utf-8") + assert "审核智能体" in content + assert 'id="summaryPanel"' in content + assert f'action="{reverse("chat")}"' in content + assert f'href="{reverse("chat")}?conversation={conversation.pk}"' in content + + +def test_legacy_home_conversation_redirects_to_chat(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="旧入口会话") + client.force_login(user) + + response = client.get(f"{reverse('home')}?conversation={conversation.pk}") + + assert response.status_code == 302 + assert response["Location"] == f"{reverse('chat')}?conversation={conversation.pk}" diff --git a/tests/test_knowledge_base.py b/tests/test_knowledge_base.py new file mode 100644 index 0000000..dec1515 --- /dev/null +++ b/tests/test_knowledge_base.py @@ -0,0 +1,345 @@ +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse + +from review_agent.knowledge_base import ( + build_knowledge_base_context, + delete_document, + index_managed_document, + search_knowledge_base, + update_document, +) +from review_agent.views import rebuild_knowledge_base_index +from review_agent.models import KnowledgeBaseDocument + + +pytestmark = pytest.mark.django_db + + +def test_knowledge_base_context_reports_rule_and_sources(): + context = build_knowledge_base_context() + + assert context["rule"]["code"] == "nmpa_ivd_registration_v1" + assert context["rule"]["requirement_count"] > 0 + assert context["source_count"] > 0 + assert context["collection_name"] == "nmpa_ivd_registration_v1" + assert not any("模拟题二" in source["relative_path"] for source in context["sources"]) + + +def test_knowledge_base_page_requires_login(client): + response = client.get(reverse("knowledge_base_manager")) + + assert response.status_code == 302 + + +def test_knowledge_base_page_renders_for_user(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + client.force_login(user) + + response = client.get(reverse("knowledge_base_manager")) + + assert response.status_code == 200 + assert "知识库管理" in response.content.decode("utf-8") + assert "RAG 检索测试" in response.content.decode("utf-8") + content = response.content.decode("utf-8") + tabbar = content[content.index('
    ", content.index('
    = 0 + assert calls == ["rebuild"] + + +def test_rebuild_knowledge_base_index_requests_reset(settings, tmp_path, monkeypatch): + settings.MEDIA_ROOT = tmp_path + settings.REGULATORY_RAG_CHROMA_PATH = tmp_path / "chroma" + settings.REGULATORY_RAG_CHROMA_PATH.mkdir() + stale_file = settings.REGULATORY_RAG_CHROMA_PATH / "chroma.sqlite3" + stale_file.write_text("stale", encoding="utf-8") + calls = [] + + monkeypatch.setattr("review_agent.views.load_rule_file", lambda: {"source_material_dir": "docs/0.原始材料"}) + monkeypatch.setattr("review_agent.views.get_embedding_provider", lambda: "provider") + monkeypatch.setattr( + "review_agent.views.build_chroma_index", + lambda source_dir, embedding_provider, reset=False: calls.append( + { + "source_dir": source_dir, + "embedding_provider": embedding_provider, + "reset": reset, + } + ) + or 8, + ) + + payload = rebuild_knowledge_base_index() + + assert payload["chunk_count"] == 8 + assert calls[0]["embedding_provider"] == "provider" + assert calls[0]["reset"] is True + + +def test_knowledge_base_search_rejects_blank_query(): + payload = search_knowledge_base("") + + assert payload["results"] == [] + assert "请输入" in payload["error_message"] + + +def test_knowledge_base_search_filters_deleted_managed_documents(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + deleted_document = KnowledgeBaseDocument.objects.create( + user=user, + display_name="孙之烨简历", + original_name="孙之烨-260510.pdf", + storage_path="knowledge_base/resume.pdf", + file_size=1, + status=KnowledgeBaseDocument.Status.DELETED, + is_active=False, + indexed_chunk_count=7, + ) + + monkeypatch.setattr( + "review_agent.knowledge_base.retrieve_citations", + lambda *args, **kwargs: [ + { + "source": "用户知识库/1/1/孙之烨-260510.pdf", + "text": "孙之烨负责审核智能体项目。", + "score": 0.2, + "metadata": {"source_type": "managed_document", "document_id": deleted_document.pk}, + }, + { + "source": "法规材料.doc", + "text": "注册检验报告要求。", + "score": 0.3, + "metadata": {"source_type": "regulatory_document"}, + }, + ], + ) + + payload = search_knowledge_base("孙之烨是谁") + + assert [item["source"] for item in payload["results"]] == ["法规材料.doc"] + + +def test_knowledge_base_search_api_returns_payload(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + client.force_login(user) + + response = client.post(reverse("knowledge_base_search"), {"query": "注册检验报告要求"}) + + assert response.status_code == 200 + assert set(response.json()) == {"query", "results", "error_message"} + + +def test_knowledge_base_document_crud_api(client, settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + settings.REGULATORY_RAG_CHROMA_PATH = tmp_path / "chroma" + settings.REGULATORY_RAG_PROVIDER = "deterministic" + user = django_user_model.objects.create_user(username="owner", password="pass") + client.force_login(user) + + upload_response = client.post( + reverse("knowledge_base_document_list"), + { + "display_name": "注册检验报告要求", + "description": "用于法规依据检索", + "is_active": "true", + "file": SimpleUploadedFile("report.md", b"# report", content_type="text/markdown"), + }, + ) + + assert upload_response.status_code == 200 + document_id = upload_response.json()["document"]["id"] + document = KnowledgeBaseDocument.objects.get(pk=document_id) + assert document.display_name == "注册检验报告要求" + assert document.indexed_chunk_count > 0 + + list_response = client.get(reverse("knowledge_base_document_list")) + assert list_response.status_code == 200 + assert list_response.json()["documents"][0]["display_name"] == "注册检验报告要求" + + detail_response = client.get(reverse("knowledge_base_document_detail", args=[document_id])) + assert detail_response.status_code == 200 + assert detail_response.json()["document"]["original_name"] == "report.md" + assert "已入库" in detail_response.json()["document"]["indexed_label"] + + patch_response = client.patch( + reverse("knowledge_base_document_detail", args=[document_id]), + data='{"display_name": "更新后的法规材料", "is_active": false}', + content_type="application/json", + ) + + assert patch_response.status_code == 200 + assert patch_response.json()["document"]["display_name"] == "更新后的法规材料" + assert patch_response.json()["document"]["is_active"] is False + + delete_response = client.delete(reverse("knowledge_base_document_detail", args=[document_id])) + + assert delete_response.status_code == 200 + assert KnowledgeBaseDocument.objects.get(pk=document_id).status == KnowledgeBaseDocument.Status.DELETED + + +def test_delete_document_removes_managed_chunks_from_index(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + document = KnowledgeBaseDocument.objects.create( + user=user, + display_name="孙之烨简历", + original_name="孙之烨-260510.pdf", + storage_path="knowledge_base/resume.pdf", + file_size=1, + indexed_chunk_count=7, + metadata={"index_status": "indexed", "index_error": ""}, + ) + deleted_filters = [] + + class FakeCollection: + def delete(self, where): + deleted_filters.append(where) + + monkeypatch.setattr("review_agent.knowledge_base._load_chroma_collection", lambda: FakeCollection()) + + delete_document(document) + + document.refresh_from_db() + assert document.status == KnowledgeBaseDocument.Status.DELETED + assert document.is_active is False + assert document.indexed_chunk_count == 0 + assert document.metadata["index_status"] == "deleted" + assert deleted_filters == [{"document_id": document.pk}] + + +def test_disabling_document_removes_managed_chunks_from_index(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + document = KnowledgeBaseDocument.objects.create( + user=user, + display_name="孙之烨简历", + original_name="孙之烨-260510.pdf", + storage_path="knowledge_base/resume.pdf", + file_size=1, + status=KnowledgeBaseDocument.Status.ACTIVE, + is_active=True, + indexed_chunk_count=7, + metadata={"index_status": "indexed", "index_error": ""}, + ) + deleted_filters = [] + + class FakeCollection: + def delete(self, where): + deleted_filters.append(where) + + monkeypatch.setattr("review_agent.knowledge_base._load_chroma_collection", lambda: FakeCollection()) + + update_document(document, {"is_active": False}) + + document.refresh_from_db() + assert document.status == KnowledgeBaseDocument.Status.DISABLED + assert document.is_active is False + assert document.indexed_chunk_count == 0 + assert document.metadata["index_status"] == "disabled" + assert deleted_filters == [{"document_id": document.pk}] + + +def test_inactive_document_manual_index_clears_existing_chunks(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + document = KnowledgeBaseDocument.objects.create( + user=user, + display_name="孙之烨简历", + original_name="孙之烨-260510.pdf", + storage_path="knowledge_base/resume.pdf", + file_size=1, + status=KnowledgeBaseDocument.Status.DISABLED, + is_active=False, + indexed_chunk_count=7, + metadata={"index_status": "indexed", "index_error": ""}, + ) + deleted_filters = [] + + class FakeCollection: + def delete(self, where): + deleted_filters.append(where) + + monkeypatch.setattr("review_agent.knowledge_base._load_chroma_collection", lambda: FakeCollection()) + + chunk_count = index_managed_document(document) + + document.refresh_from_db() + assert chunk_count == 0 + assert document.indexed_chunk_count == 0 + assert document.metadata["index_status"] == "disabled" + assert deleted_filters == [{"document_id": document.pk}] + + +def test_knowledge_base_document_api_is_scoped_to_owner(client, django_user_model): + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + document = KnowledgeBaseDocument.objects.create( + user=owner, + display_name="法规材料", + original_name="a.md", + storage_path="knowledge_base/a.md", + file_size=1, + ) + client.force_login(other) + + response = client.patch( + reverse("knowledge_base_document_detail", args=[document.pk]), + data='{"display_name": "越权修改"}', + content_type="application/json", + ) + + assert response.status_code == 404 + + +def test_knowledge_base_document_manual_index_api(client, settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + settings.REGULATORY_RAG_CHROMA_PATH = tmp_path / "chroma" + settings.REGULATORY_RAG_PROVIDER = "deterministic" + user = django_user_model.objects.create_user(username="owner", password="pass") + client.force_login(user) + source_path = tmp_path / "manual.md" + source_path.write_text("# manual\n注册检验报告要求", encoding="utf-8") + document = KnowledgeBaseDocument.objects.create( + user=user, + display_name="manual.md", + original_name="manual.md", + storage_path=str(source_path), + file_size=source_path.stat().st_size, + indexed_chunk_count=0, + ) + + response = client.post(reverse("knowledge_base_document_index", args=[document.pk])) + + assert response.status_code == 200 + document.refresh_from_db() + assert document.indexed_chunk_count > 0 + assert "已入库" in response.json()["document"]["indexed_label"] diff --git a/tests/test_llm_streaming.py b/tests/test_llm_streaming.py new file mode 100644 index 0000000..c5a4545 --- /dev/null +++ b/tests/test_llm_streaming.py @@ -0,0 +1,54 @@ +import io +from urllib import request + +import pytest + +from review_agent.llm import build_messages, stream_reply +from review_agent.models import Conversation + + +pytestmark = pytest.mark.django_db + + +class FakeStreamingResponse: + def __iter__(self): + return iter( + [ + b'data: {"choices":[{"delta":{"content":"A"}}]}\n\n', + b"data: not-json\n\n", + b'data: {"choices":[{"delta":{"content":"B"}}]}\n\n', + b"data: [DONE]\n\n", + ] + ) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + +def test_stream_reply_skips_malformed_sse_data(monkeypatch, settings, django_user_model): + settings.LLM_API_KEY = "key" + settings.LLM_MODEL = "model" + settings.LLM_BASE_URL = "https://example.test/v1" + monkeypatch.setattr(request, "urlopen", lambda req, timeout: FakeStreamingResponse()) + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + chunks = list(stream_reply(conversation, "你好")) + + assert chunks == ["A", "B"] + + +def test_build_messages_includes_knowledge_context(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + messages = build_messages(conversation, "孙之烨是谁", knowledge_context="来源:简历\n孙之烨负责审核智能体项目。") + + assert messages[0]["role"] == "system" + assert messages[1]["role"] == "system" + assert "全局知识库" in messages[1]["content"] + assert "孙之烨负责审核智能体项目" in messages[1]["content"] + assert messages[-1] == {"role": "user", "content": "孙之烨是谁"} diff --git a/tests/test_logging_filters.py b/tests/test_logging_filters.py new file mode 100644 index 0000000..629ecd3 --- /dev/null +++ b/tests/test_logging_filters.py @@ -0,0 +1,31 @@ +import logging + +from review_agent.logging_filters import SuppressWorkflowStatusPollFilter + + +def test_suppress_workflow_status_poll_filter_hides_status_poll_requests(): + record = logging.LogRecord( + name="django.server", + level=logging.INFO, + pathname="", + lineno=1, + msg='"GET /api/review-agent/regulatory-review/7/status/ HTTP/1.1" 200 1660', + args=(), + exc_info=None, + ) + + assert SuppressWorkflowStatusPollFilter().filter(record) is False + + +def test_suppress_workflow_status_poll_filter_keeps_other_requests(): + record = logging.LogRecord( + name="django.server", + level=logging.INFO, + pathname="", + lineno=1, + msg='"POST /api/review-agent/regulatory-review/7/conditions/ HTTP/1.1" 200 256', + args=(), + exc_info=None, + ) + + assert SuppressWorkflowStatusPollFilter().filter(record) is True diff --git a/tests/test_regulatory_completeness.py b/tests/test_regulatory_completeness.py new file mode 100644 index 0000000..51944fc --- /dev/null +++ b/tests/test_regulatory_completeness.py @@ -0,0 +1,72 @@ +import pytest + +from review_agent.models import Conversation, FileSummaryBatch, FileSummaryItem +from review_agent.regulatory_review.services.completeness_check import run_completeness_check +from review_agent.regulatory_review.services.rule_loader import load_rule_file + + +pytestmark = pytest.mark.django_db + + +def test_completeness_check_matches_existing_files_and_reports_missing(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-CHECK", + status=FileSummaryBatch.Status.SUCCESS, + ) + FileSummaryItem.objects.create( + batch=batch, + file_index=1, + file_name="产品技术要求.docx", + file_type="docx", + relative_path="产品技术要求.docx", + storage_path="x/product.docx", + ) + FileSummaryItem.objects.create( + batch=batch, + file_index=2, + file_name="说明书.docx", + file_type="docx", + relative_path="说明书.docx", + storage_path="x/ifu.docx", + ) + + findings = run_completeness_check(batch, load_rule_file()) + + titles = [finding.title for finding in findings] + assert "缺少3.4注册检验报告" in titles + assert "缺少产品技术要求" not in titles + missing = next(finding for finding in findings if finding.rule_code == "registration_test_report") + assert missing.severity == "blocking" + assert missing.category == "completeness" + + +def test_completeness_check_matches_attachment4_directory_names(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-A4", + status=FileSummaryBatch.Status.SUCCESS, + ) + FileSummaryItem.objects.create( + batch=batch, + file_index=1, + directory_level="1. 监管信息 / 1.2 申请表", + file_name="注册申请表.pdf", + file_type="pdf", + relative_path="1.监管信息/1.2申请表/注册申请表.pdf", + storage_path="x/app.pdf", + ) + + findings = run_completeness_check(batch, load_rule_file()) + + assert not any(finding.rule_code == "attachment4_1_2_application_form" for finding in findings) + missing_qms = next(finding for finding in findings if finding.rule_code == "attachment4_6_quality_system") + assert missing_qms.title == "缺少6质量管理体系文件" + assert missing_qms.severity == "high" + assert missing_qms.evidence["searched_fields"] == ["file_name", "relative_path", "directory_level"] diff --git a/tests/test_regulatory_condition.py b/tests/test_regulatory_condition.py new file mode 100644 index 0000000..e397f83 --- /dev/null +++ b/tests/test_regulatory_condition.py @@ -0,0 +1,306 @@ +import json + +import pytest +from django.urls import reverse + +from review_agent.models import ( + Conversation, + FileSummaryBatch, + FileSummaryItem, + RegulatoryReviewBatch, + WorkflowEvent, + WorkflowNodeRun, +) +from review_agent.regulatory_review.services.info_extract import detect_regulatory_condition_candidates +from review_agent.regulatory_review.workflow import ( + create_regulatory_review_batch, + start_regulatory_review_workflow, +) + + +pytestmark = pytest.mark.django_db + + +def test_detect_regulatory_condition_candidates_from_summary_items(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-COND", + status=FileSummaryBatch.Status.SUCCESS, + product_name="甲胎蛋白检测试剂盒", + ) + FileSummaryItem.objects.create( + batch=summary, + file_index=1, + directory_level="临床评价资料", + file_name="免临床评价资料.docx", + file_type="docx", + relative_path="4.临床评价资料/免临床评价资料.docx", + storage_path="missing.docx", + ) + + candidates = detect_regulatory_condition_candidates(summary) + + assert candidates["product_category"]["suggested"] == "体外诊断试剂" + assert candidates["registration_type"]["suggested"] == "首次注册" + assert candidates["clinical_evaluation_path"]["suggested"] == "免临床" + assert candidates["product_name"]["suggested"] == "甲胎蛋白检测试剂盒" + + +def test_detect_regulatory_condition_prefers_attachment_fields_over_chapter_title(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-COND", + status=FileSummaryBatch.Status.SUCCESS, + product_name="第1章 监管信息", + ) + application = tmp_path / "application.txt" + application.write_text( + "产品名称:甲胎蛋白检测试剂盒\n型号规格:20人份/盒\n预期用途:用于人血清中甲胎蛋白检测\n注册类型:首次注册\n", + encoding="utf-8", + ) + FileSummaryItem.objects.create( + batch=summary, + file_index=1, + directory_level="1. 监管信息 / 1.2 申请表", + file_name="申请表.txt", + file_type="txt", + relative_path="1.监管信息/申请表.txt", + storage_path=str(application), + ) + + candidates = detect_regulatory_condition_candidates(summary) + + assert candidates["product_name"]["suggested"] == "甲胎蛋白检测试剂盒" + assert candidates["model_spec"]["suggested"] == "20人份/盒" + assert candidates["intended_use"]["suggested"] == "用于人血清中甲胎蛋白检测" + + +def test_detect_regulatory_condition_keeps_wrapped_product_name(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-COND", + status=FileSummaryBatch.Status.SUCCESS, + product_name="第1章 监管信息", + ) + application = tmp_path / "application.txt" + application.write_text( + "产品名称:呼吸道合胞病毒、肺炎支原体核酸检测试剂盒\n" + "(荧光PCR法)\n" + "型号规格:24人份/盒\n" + "预期用途:用于呼吸道合胞病毒、肺炎支原体核酸检测\n", + encoding="utf-8", + ) + FileSummaryItem.objects.create( + batch=summary, + file_index=1, + directory_level="1. 监管信息 / 1.2 申请表", + file_name="申请表.txt", + file_type="txt", + relative_path="1.监管信息/申请表.txt", + storage_path=str(application), + ) + + candidates = detect_regulatory_condition_candidates(summary) + + assert candidates["product_name"]["suggested"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒 (荧光PCR法)" + assert candidates["model_spec"]["suggested"] == "24人份/盒" + + +def test_detect_regulatory_condition_uses_llm_review_for_better_product_name( + monkeypatch, settings, tmp_path, django_user_model +): + settings.MEDIA_ROOT = tmp_path + settings.REGULATORY_LLM_REVIEW_ALLOW_TEST_CALLS = True + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-COND", + status=FileSummaryBatch.Status.SUCCESS, + product_name="第1章 监管信息", + ) + application = tmp_path / "application.txt" + application.write_text( + "产品名称:呼吸道合胞病毒、肺炎支原体核酸检测试剂盒\n" + "型号规格:24人份/盒\n", + encoding="utf-8", + ) + FileSummaryItem.objects.create( + batch=summary, + file_index=1, + directory_level="1. 监管信息 / 1.2 申请表", + file_name="申请表.txt", + file_type="txt", + relative_path="1.监管信息/申请表.txt", + storage_path=str(application), + ) + + monkeypatch.setattr( + "review_agent.regulatory_review.services.llm_review.generate_completion", + lambda messages, temperature=0.0: json.dumps( + {"fields": {"产品名称": "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒 (荧光PCR法)"}}, + ensure_ascii=False, + ), + ) + + candidates = detect_regulatory_condition_candidates(summary) + + assert candidates["product_name"]["suggested"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒 (荧光PCR法)" + assert candidates["product_name"]["source"] == "llm" + + +def test_detect_regulatory_condition_infers_fields_from_unlabeled_attachment_text( + settings, tmp_path, django_user_model +): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-COND", + status=FileSummaryBatch.Status.SUCCESS, + product_name="第1章 监管信息", + ) + standard_list = tmp_path / "standard_list.txt" + standard_list.write_text( + "国家药品监督管理局:\n" + "卡尤迪生物科技宜兴有限公司申请境内第三类体外诊断试剂" + "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)产品注册。\n", + encoding="utf-8", + ) + product_list = tmp_path / "product_list.txt" + product_list.write_text( + "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒\n" + "(荧光PCR法)\n" + "产品的包装规格\n" + "24人份/盒、48人份/盒\n", + encoding="utf-8", + ) + FileSummaryItem.objects.create( + batch=summary, + file_index=1, + directory_level="第1章 监管信息", + file_name="符合标准的清单.txt", + file_type="txt", + relative_path="第1章 监管信息/符合标准的清单.txt", + storage_path=str(standard_list), + ) + FileSummaryItem.objects.create( + batch=summary, + file_index=2, + directory_level="第1章 监管信息", + file_name="产品列表.txt", + file_type="txt", + relative_path="第1章 监管信息/产品列表.txt", + storage_path=str(product_list), + ) + + candidates = detect_regulatory_condition_candidates(summary) + + assert candidates["product_category"]["suggested"] == "体外诊断试剂" + assert candidates["product_name"]["suggested"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)" + assert candidates["product_name"]["source"] == "inferred" + assert candidates["model_spec"]["suggested"] == "24人份/盒、48人份/盒" + + +def test_workflow_pauses_before_rule_scope_until_conditions_confirmed(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-COND", + status=FileSummaryBatch.Status.SUCCESS, + product_name="甲胎蛋白检测试剂盒", + ) + batch = create_regulatory_review_batch( + conversation=conversation, + user=user, + source_summary_batch=summary, + ) + + start_regulatory_review_workflow(batch, async_run=False) + + batch.refresh_from_db() + condition_node = WorkflowNodeRun.objects.get( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + node_code="condition_confirm", + ) + rule_scope_node = WorkflowNodeRun.objects.get( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + node_code="rule_scope", + ) + assert batch.status == RegulatoryReviewBatch.Status.WAITING_USER + assert condition_node.status == WorkflowNodeRun.Status.WAITING_USER + assert rule_scope_node.status == WorkflowNodeRun.Status.PENDING + assert batch.condition_json["candidates"]["product_category"]["suggested"] == "体外诊断试剂" + assert WorkflowEvent.objects.filter( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + event_type="waiting_user", + ).exists() + + +def test_confirm_conditions_endpoint_resumes_workflow(client, settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + settings.REGULATORY_REVIEW_ASYNC = False + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-COND", + status=FileSummaryBatch.Status.SUCCESS, + product_name="甲胎蛋白检测试剂盒", + ) + batch = create_regulatory_review_batch( + conversation=conversation, + user=user, + source_summary_batch=summary, + ) + start_regulatory_review_workflow(batch, async_run=False) + client.force_login(user) + + response = client.post( + reverse("regulatory_review_confirm_conditions", args=[batch.pk]), + data=json.dumps( + { + "conditions": { + "product_category": "体外诊断试剂", + "registration_type": "首次注册", + "clinical_evaluation_path": "免临床", + "product_name": "甲胎蛋白检测试剂盒", + "model_spec": "卡型", + "intended_use": "用于甲胎蛋白检测", + } + } + ), + content_type="application/json", + ) + + batch.refresh_from_db() + assert response.status_code == 200 + assert response.json()["batch"]["status"] == RegulatoryReviewBatch.Status.SUCCESS + assert batch.condition_json["confirmed"] is True + assert batch.condition_json["confirmed_conditions"]["model_spec"] == "卡型" + assert WorkflowNodeRun.objects.get( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + node_code="condition_confirm", + ).status == WorkflowNodeRun.Status.SUCCESS diff --git a/tests/test_regulatory_consistency.py b/tests/test_regulatory_consistency.py new file mode 100644 index 0000000..9f925e7 --- /dev/null +++ b/tests/test_regulatory_consistency.py @@ -0,0 +1,27 @@ +from review_agent.regulatory_review.services.consistency_check import run_consistency_check + + +def test_consistency_check_reports_product_name_mismatch(): + document_texts = { + "说明书.docx": "产品名称:甲胎蛋白检测试剂盒\n型号规格:20人份/盒\n预期用途:定量检测AFP", + "技术要求.docx": "产品名称:乙肝表面抗原检测试剂盒\n型号规格:20人份/盒\n预期用途:定量检测AFP", + } + + findings = run_consistency_check(document_texts) + + assert len(findings) == 1 + assert findings[0].category == "consistency" + assert "产品名称" in findings[0].title + + +def test_consistency_check_reports_registration_scope_fields(): + document_texts = { + "申请表.docx": "管理类别:第二类\n分类编码:6840\n注册类型:首次注册\n临床评价路径:免临床", + "综述资料.docx": "管理类别:第三类\n分类编码:6840\n注册类型:首次注册\n临床评价路径:临床试验", + } + + findings = run_consistency_check(document_texts) + titles = [finding.title for finding in findings] + + assert "管理类别在不同文件中不一致" in titles + assert "临床评价路径在不同文件中不一致" in titles diff --git a/tests/test_regulatory_export.py b/tests/test_regulatory_export.py new file mode 100644 index 0000000..fbe8870 --- /dev/null +++ b/tests/test_regulatory_export.py @@ -0,0 +1,49 @@ +import json + +import pytest + +from review_agent.models import ( + Conversation, + ExportedSummaryFile, + FileSummaryBatch, + RegulatoryIssue, + RegulatoryReviewBatch, +) +from review_agent.regulatory_review.services.export import export_review_results + + +pytestmark = pytest.mark.django_db + + +def test_export_review_results_creates_markdown_excel_and_json(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-OK") + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-EXPORT", + risk_summary={"blocking": 1}, + ) + RegulatoryIssue.objects.create( + batch=batch, + rule_code="registration_test_report", + category=RegulatoryIssue.Category.COMPLETENESS, + severity=RegulatoryIssue.Severity.BLOCKING, + title="缺少注册检验报告", + suggestion="请补充注册检验报告并复核。", + ) + + exports = export_review_results(batch) + + assert {export.export_type for export in exports} == { + ExportedSummaryFile.ExportType.MARKDOWN, + ExportedSummaryFile.ExportType.EXCEL, + ExportedSummaryFile.ExportType.JSON, + } + json_export = next(export for export in exports if export.export_type == ExportedSummaryFile.ExportType.JSON) + payload = json.loads(open(json_export.storage_path, encoding="utf-8").read()) + assert payload["batch_no"] == "RR-EXPORT" + assert payload["issues"][0]["title"] == "缺少注册检验报告" diff --git a/tests/test_regulatory_frontend.py b/tests/test_regulatory_frontend.py new file mode 100644 index 0000000..9964434 --- /dev/null +++ b/tests/test_regulatory_frontend.py @@ -0,0 +1,265 @@ +import pytest +from django.urls import reverse + +from review_agent.models import ( + Conversation, + FileSummaryBatch, + FileSummaryItem, + RegulatoryArtifact, + RegulatoryNotificationRecord, + RegulatoryReviewBatch, + WorkflowNotificationRecord, + WorkflowNodeRun, +) + + +pytestmark = pytest.mark.django_db + + +def test_workspace_renders_regulatory_workflow_card(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + regulatory = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-CARD", + status=RegulatoryReviewBatch.Status.SUCCESS, + risk_summary={"blocking": 1, "high": 1}, + ) + WorkflowNodeRun.objects.create( + workflow_type="regulatory_review", + workflow_batch_id=regulatory.pk, + node_group="regulatory_review", + node_code="risk_assess", + node_name="风险评估", + status=WorkflowNodeRun.Status.SUCCESS, + progress=100, + ) + client.force_login(user) + + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") + + content = response.content.decode("utf-8") + assert "RR-CARD" in content + assert 'data-workflow-type="regulatory_review"' in content + assert "阻断项 1" in content + assert "风险评估" in content + assert "data-regulatory-status-url-template" in content + + +def test_workspace_renders_condition_confirmation_form(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + regulatory = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-WAIT", + status=RegulatoryReviewBatch.Status.WAITING_USER, + condition_json={ + "confirmed": False, + "candidates": { + "product_category": { + "label": "产品类别", + "input_type": "select", + "options": ["体外诊断试剂", "医疗器械", "其他"], + "suggested": "体外诊断试剂", + }, + "product_name": { + "label": "产品名称", + "input_type": "text", + "suggested": "甲胎蛋白检测试剂盒", + }, + }, + }, + ) + WorkflowNodeRun.objects.create( + workflow_type="regulatory_review", + workflow_batch_id=regulatory.pk, + node_group="condition_confirm", + node_code="condition_confirm", + node_name="适用条件确认", + status=WorkflowNodeRun.Status.WAITING_USER, + progress=50, + ) + client.force_login(user) + + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") + + content = response.content.decode("utf-8") + assert "适用条件确认" in content + assert "data-condition-confirm-form" in content + assert "体外诊断试剂" in content + assert "甲胎蛋白检测试剂盒" in content + form_index = content.index("data-condition-confirm-form") + summary_index = content.index('id="summaryPanel"') + assert form_index < summary_index + assert "data-condition-confirm-form" not in content[summary_index:] + + +def test_workspace_refreshes_incomplete_condition_confirmation_candidates(client, settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + product_name="第1章 监管信息", + ) + application = tmp_path / "application.txt" + application.write_text( + "卡尤迪生物科技宜兴有限公司申请境内第三类体外诊断试剂" + "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)产品注册。", + encoding="utf-8", + ) + FileSummaryItem.objects.create( + batch=summary, + file_index=1, + directory_level="第1章 监管信息", + file_name="符合标准的清单.txt", + file_type="txt", + relative_path="第1章 监管信息/符合标准的清单.txt", + storage_path=str(application), + ) + RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-WAIT-EMPTY", + status=RegulatoryReviewBatch.Status.WAITING_USER, + condition_json={ + "confirmed": False, + "candidates": { + "product_category": {"label": "产品类别", "input_type": "select", "options": ["其他"], "suggested": "其他"}, + "product_name": {"label": "产品名称", "input_type": "text", "suggested": ""}, + }, + }, + ) + client.force_login(user) + + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") + + content = response.content.decode("utf-8") + assert "体外诊断试剂" in content + assert "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)" in content + + +def test_workspace_renders_rectification_actions_and_summaries(client, tmp_path, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + regulatory = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-RECTIFY", + status=RegulatoryReviewBatch.Status.SUCCESS, + ) + record_path = tmp_path / "review_record.json" + record_path.write_text('{"items":[{"status":"review_passed"}]}', encoding="utf-8") + RegulatoryArtifact.objects.create( + batch=regulatory, + artifact_type=RegulatoryArtifact.ArtifactType.JSON, + name="review_record.json", + storage_path=str(record_path), + metadata={"artifact": "review_record"}, + ) + RegulatoryNotificationRecord.objects.create( + batch=regulatory, + channel=RegulatoryNotificationRecord.Channel.MOCK, + target="法规整改负责人", + status=RegulatoryNotificationRecord.Status.SENT, + payload={"title": "缺少申请表"}, + ) + client.force_login(user) + + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") + + content = response.content.decode("utf-8") + assert "data-rectification-action=\"full-review\"" in content + assert "data-rectification-action=\"issue-review\"" in content + assert "通知 1" in content + assert "复核记录 1" in content + + +def test_frontend_selects_status_url_by_workflow_type(): + script = open("static/js/app.js", encoding="utf-8").read() + + assert "workflow_type" in script + assert "data-regulatory-status-url-template" in script + assert "statusUrlForWorkflow" in script + assert "bindConditionConfirmForms" in script + assert "data-condition-confirm-form" in script + assert "ensureConditionConfirmationCard" in script + assert "condition_confirmation" in script + assert "bindRectificationActionButtons" in script + assert "data-rectification-action" in script + + +def test_frontend_polls_regulatory_workflow_with_explicit_workflow_type(): + script = open("static/js/app.js", encoding="utf-8").read() + + assert "function startWorkflowPolling(batchId, workflow_type)" in script + assert "startWorkflowPolling(payload.batch_id, payload.workflow_type)" in script + assert 'startWorkflowPolling(batchId, "regulatory_review")' in script + assert 'workflow_type || (card ? card.getAttribute("data-workflow-type") || "file_summary" : "file_summary")' in script + + +def test_frontend_keeps_single_condition_confirmation_prompt(): + script = open("static/js/app.js", encoding="utf-8").read() + + assert "data-condition-confirmation-card" in script + assert "removeStaleConditionConfirmationCards" in script + assert '[data-condition-confirmation-card]' in script + + +def test_regulatory_status_includes_failed_feishu_notification(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-RR") + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-FEISHU", + ) + WorkflowNotificationRecord.objects.create( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + workflow_batch_no=batch.batch_no, + workflow_status=batch.status, + dedupe_key=f"regulatory_review:{batch.pk}:{batch.status}", + trigger_user=user, + channel=WorkflowNotificationRecord.Channel.FEISHU_API, + target="负责人", + send_status=WorkflowNotificationRecord.SendStatus.FAILED, + message_title="法规核查完成", + error_message="bad receive_id", + ) + client.force_login(user) + + response = client.get(f"/api/review-agent/regulatory-review/{batch.pk}/status/") + + payload = response.json() + assert payload["latest_notification"]["status_label"] == "飞书通知失败" + assert payload["latest_notification"]["error_message"] == "bad receive_id" diff --git a/tests/test_regulatory_info_package_field_extract.py b/tests/test_regulatory_info_package_field_extract.py new file mode 100644 index 0000000..b84754d --- /dev/null +++ b/tests/test_regulatory_info_package_field_extract.py @@ -0,0 +1,88 @@ +import json + +from review_agent.regulatory_info_package.schemas import InstructionExtractResult +from review_agent.regulatory_info_package.services.field_extract import extract_fields_by_rules, run_parallel_extract + + +def test_extract_fields_by_rules_finds_product_name_and_storage(): + instruction = InstructionExtractResult( + source_file_name="目标产品说明书.docx", + paragraphs=["产品名称:新型冠状病毒检测试剂盒", "储存条件:2-8℃保存"], + sections={}, + tables=[], + component_tables=[], + front_text="产品名称:新型冠状病毒检测试剂盒\n储存条件:2-8℃保存", + ) + + result = extract_fields_by_rules(instruction) + + assert result["product_name"]["value"] == "新型冠状病毒检测试剂盒" + assert result["storage_condition"]["value"] == "2-8℃保存" + + +def test_extract_fields_by_rules_uses_registrant_or_manufacturer_for_applicant(): + instruction = InstructionExtractResult( + source_file_name="目标产品说明书.docx", + paragraphs=[ + "注册人/售后服务单位名称:卡尤迪生物科技宜兴有限公司", + "生产企业名称:卡尤迪生物科技宜兴有限公司", + "生产企业住所:宜兴经济技术开发区杏里路10号宜兴光电产业园4幢101室、102室", + "联系方式: 0510-80330909, 0510-80330919", + "生产地址:江苏省宜兴经济技术开发区杏里路10号宜兴光电产业园4幢102室", + ], + sections={}, + tables=[], + component_tables=[], + front_text="", + ) + + result = extract_fields_by_rules(instruction) + + assert result["applicant_name"]["value"] == "卡尤迪生物科技宜兴有限公司" + assert result["manufacturer_name"]["value"] == "卡尤迪生物科技宜兴有限公司" + assert result["applicant_address"]["value"] == "宜兴经济技术开发区杏里路10号宜兴光电产业园4幢101室、102室" + assert result["applicant_contact"]["value"] == "0510-80330909, 0510-80330919" + assert result["production_address"]["value"] == "江苏省宜兴经济技术开发区杏里路10号宜兴光电产业园4幢102室" + + +def test_extract_fields_by_rules_serializes_component_table_and_notes(): + instruction = InstructionExtractResult( + source_file_name="目标产品说明书.docx", + paragraphs=[], + sections={"【主要组成成分】": "表1 规格A大包装试剂盒组成成分\n注:不同批号试剂盒中各组分不得互换使用。"}, + tables=[], + component_tables=[ + { + "header": ["组分", "主要组成成分", "规格(24人份/盒)", "规格(48人份/盒)"], + "rows": [ + ["PCR反应液 I", "逆转录酶、Taq酶", "840μL/管×1管", "840μL/管×2管"], + ["阳性对照品", "含目的片段的假病毒", "600μL/管×2管", "1200μL/管×2管"], + ], + } + ], + front_text="", + ) + + result = extract_fields_by_rules(instruction) + payload = json.loads(result["component_table"]["value"]) + + assert payload["header"][0:2] == ["组分", "主要组成成分"] + assert payload["rows"][0][0] == "PCR反应液 I" + assert result["component_notes"]["value"] == "表1 规格A大包装试剂盒组成成分\n注:不同批号试剂盒中各组分不得互换使用。" + + +def test_run_parallel_extract_keeps_rule_result_when_llm_fails(): + instruction = InstructionExtractResult( + source_file_name="目标产品说明书.docx", + paragraphs=["产品名称:测试产品"], + sections={}, + tables=[], + component_tables=[], + front_text="产品名称:测试产品", + ) + + result = run_parallel_extract(instruction, llm_extract_func=lambda _instruction: (_ for _ in ()).throw(ValueError("bad llm"))) + + assert result["regex_results"]["product_name"]["value"] == "测试产品" + assert result["llm_results"] == {} + assert result["llm_error"] diff --git a/tests/test_regulatory_info_package_field_merge.py b/tests/test_regulatory_info_package_field_merge.py new file mode 100644 index 0000000..18192ed --- /dev/null +++ b/tests/test_regulatory_info_package_field_merge.py @@ -0,0 +1,24 @@ +from review_agent.regulatory_info_package.services.field_merge import merge_fields + + +def test_merge_fields_marks_missing_llm_only_and_conflict(): + merged, summary = merge_fields( + { + "product_name": {"value": "规则产品", "evidence": "说明书", "confidence": 0.8, "label": "产品名称"}, + "applicant_name": {"value": "", "evidence": "", "confidence": 0.0, "label": "申请人名称"}, + "package_specification": {"value": "24人份/盒", "evidence": "表格", "confidence": 0.7, "label": "包装规格"}, + }, + { + "intended_use": {"value": "用于检测", "evidence": "LLM", "confidence": 0.6, "label": "预期用途"}, + "package_specification": {"value": "48人份/盒", "evidence": "LLM", "confidence": 0.6, "label": "包装规格"}, + }, + ) + + assert merged["applicant_name"].value == "/" + assert merged["applicant_name"].highlight_reason == "missing" + assert merged["intended_use"].highlight_reason == "llm_only" + assert merged["package_specification"].value == "24人份/盒" + assert merged["package_specification"].highlight_reason == "conflict" + assert any(item["field_key"] == "applicant_name" for item in summary["missing_fields"]) + assert len(summary["llm_only_fields"]) == 1 + assert len(summary["conflict_fields"]) == 1 diff --git a/tests/test_regulatory_info_package_frontend.py b/tests/test_regulatory_info_package_frontend.py new file mode 100644 index 0000000..2b10f0b --- /dev/null +++ b/tests/test_regulatory_info_package_frontend.py @@ -0,0 +1,45 @@ +import pytest +from django.urls import reverse + +from review_agent.models import Conversation, RegulatoryInfoPackageBatch, WorkflowNodeRun + + +pytestmark = pytest.mark.django_db + + +def test_workspace_renders_regulatory_info_package_chip_and_card(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = RegulatoryInfoPackageBatch.objects.create( + conversation=conversation, + user=user, + batch_no="RIP-CARD", + status=RegulatoryInfoPackageBatch.Status.SUCCESS, + generated_files=[{"status": "success"} for _ in range(7)], + ) + WorkflowNodeRun.objects.create( + workflow_type="regulatory_info_package", + workflow_batch_id=batch.pk, + node_group="regulatory_info_package", + node_code="zip_export", + node_name="打包下载", + status=WorkflowNodeRun.Status.SUCCESS, + progress=100, + ) + client.force_login(user) + + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") + content = response.content.decode("utf-8") + + assert "第1章监管信息" in content + assert 'data-workflow-type="regulatory_info_package"' in content + assert "data-regulatory-info-package-status-url-template" in content + assert "RIP-CARD" in content + + +def test_frontend_selects_regulatory_info_package_status_url(): + script = open("static/js/app.js", encoding="utf-8").read() + + assert 'workflow_type === "regulatory_info_package"' in script + assert "data-regulatory-info-package-status-url-template" in script + diff --git a/tests/test_regulatory_info_package_input_select.py b/tests/test_regulatory_info_package_input_select.py new file mode 100644 index 0000000..a580aa5 --- /dev/null +++ b/tests/test_regulatory_info_package_input_select.py @@ -0,0 +1,48 @@ +import pytest + +from review_agent.models import Conversation, FileAttachment +from review_agent.regulatory_info_package.services.input_select import select_instruction_input + + +pytestmark = pytest.mark.django_db + + +def test_select_instruction_input_prefers_message_filename(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + selected = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="目标产品说明书.docx", + storage_path="uploads/target.docx", + ) + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="其他说明书.docx", + storage_path="uploads/other.docx", + ) + + result = select_instruction_input(conversation, "请使用目标产品说明书生成第1章监管信息") + + assert result.status == "selected" + assert result.attachment == selected + assert result.file_name == "目标产品说明书.docx" + + +def test_select_instruction_input_waits_on_multiple_candidates(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + for name in ["A说明书.docx", "B说明书.docx"]: + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name=name, + storage_path=f"uploads/{name}", + ) + + result = select_instruction_input(conversation, "生成第1章监管信息") + + assert result.status == "waiting_user" + assert result.candidates == ["A说明书.docx", "B说明书.docx"] + diff --git a/tests/test_regulatory_info_package_instruction_extract.py b/tests/test_regulatory_info_package_instruction_extract.py new file mode 100644 index 0000000..93b9e78 --- /dev/null +++ b/tests/test_regulatory_info_package_instruction_extract.py @@ -0,0 +1,16 @@ +from pathlib import Path + +from review_agent.regulatory_info_package.services.instruction_extract import parse_instruction_docx + + +def test_parse_instruction_docx_extracts_paragraphs_and_tables(): + path = Path("docs/0.原始材料/目标产品说明书.docx") + + result = parse_instruction_docx(path) + + assert result.source_file_name == "目标产品说明书.docx" + assert result.paragraphs + assert isinstance(result.sections, dict) + assert isinstance(result.tables, list) + assert result.front_text + diff --git a/tests/test_regulatory_info_package_legacy_doc.py b/tests/test_regulatory_info_package_legacy_doc.py new file mode 100644 index 0000000..951b609 --- /dev/null +++ b/tests/test_regulatory_info_package_legacy_doc.py @@ -0,0 +1,9 @@ +from review_agent.regulatory_info_package.services.legacy_doc_document import detect_legacy_doc_capability + + +def test_detect_legacy_doc_capability_is_stable(): + capability = detect_legacy_doc_capability() + + assert capability.status in {"available", "unavailable"} + assert capability.adapter in {"WordComDocAdapter", "UnavailableLegacyDocAdapter"} + diff --git a/tests/test_regulatory_info_package_models.py b/tests/test_regulatory_info_package_models.py new file mode 100644 index 0000000..e100935 --- /dev/null +++ b/tests/test_regulatory_info_package_models.py @@ -0,0 +1,109 @@ +import pytest +from django.db import IntegrityError + +from review_agent.models import ( + Conversation, + ExportedSummaryFile, + FileAttachment, + RegulatoryInfoPackageArtifact, + RegulatoryInfoPackageBatch, + RegulatoryInfoPackageNotificationRecord, + WorkflowNodeRun, +) + + +pytestmark = pytest.mark.django_db + + +def test_regulatory_info_package_batch_defaults(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + attachment = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="目标产品说明书.docx", + storage_path="uploads/instruction.docx", + ) + + batch = RegulatoryInfoPackageBatch.objects.create( + conversation=conversation, + user=user, + source_attachment=attachment, + batch_no="RIP-20260610153000-abcdef", + source_file_name=attachment.original_name, + source_storage_path=attachment.storage_path, + ) + + assert batch.status == RegulatoryInfoPackageBatch.Status.PENDING + assert batch.output_zip_name == "第1章 监管信息(预生成版).zip" + assert batch.generated_files == [] + assert batch.missing_fields == [] + assert batch.llm_only_fields == [] + assert batch.conflict_fields == [] + assert batch.risk_notes == [] + assert batch.adapter_summary == {} + assert str(batch) == "RIP-20260610153000-abcdef" + + +def test_regulatory_info_package_artifact_and_notification(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = RegulatoryInfoPackageBatch.objects.create( + conversation=conversation, + user=user, + batch_no="RIP-20260610153100-abcdef", + ) + + artifact = RegulatoryInfoPackageArtifact.objects.create( + batch=batch, + artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.ZIP_PACKAGE, + file_format=RegulatoryInfoPackageArtifact.FileFormat.ZIP, + name="主下载包", + file_name="第1章 监管信息(预生成版).zip", + storage_path="media/regulatory_info_package/package.zip", + ) + notification = RegulatoryInfoPackageNotificationRecord.objects.create( + batch=batch, + recipient=user, + export_ids=[1, 2], + message_summary="材料包已生成", + send_status=RegulatoryInfoPackageNotificationRecord.SendStatus.SUCCESS, + ) + + assert artifact.metadata == {} + assert artifact.is_deleted is False + assert notification.channel == RegulatoryInfoPackageNotificationRecord.Channel.MOCK + assert notification.retry_count == 0 + + +def test_exported_summary_file_supports_zip_type(): + values = {value for value, _label in ExportedSummaryFile.ExportType.choices} + + assert "zip" in values + + +def test_workflow_node_run_unique_for_workflow_batch(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = RegulatoryInfoPackageBatch.objects.create( + conversation=conversation, + user=user, + batch_no="RIP-20260610153200-abcdef", + ) + + WorkflowNodeRun.objects.create( + workflow_type="regulatory_info_package", + workflow_batch_id=batch.pk, + node_group="regulatory_info_package", + node_code="prepare", + node_name="准备资料", + ) + + with pytest.raises(IntegrityError): + WorkflowNodeRun.objects.create( + workflow_type="regulatory_info_package", + workflow_batch_id=batch.pk, + node_group="regulatory_info_package", + node_code="prepare", + node_name="准备资料", + ) diff --git a/tests/test_regulatory_info_package_notification.py b/tests/test_regulatory_info_package_notification.py new file mode 100644 index 0000000..6b69ac8 --- /dev/null +++ b/tests/test_regulatory_info_package_notification.py @@ -0,0 +1,17 @@ +import pytest + +from review_agent.models import Conversation, RegulatoryInfoPackageBatch, RegulatoryInfoPackageNotificationRecord + + +pytestmark = pytest.mark.django_db + + +def test_regulatory_info_package_notification_record_defaults(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = RegulatoryInfoPackageBatch.objects.create(conversation=conversation, user=user, batch_no="RIP-NOTIFY") + + record = RegulatoryInfoPackageNotificationRecord.objects.create(batch=batch, recipient=user) + + assert record.channel == RegulatoryInfoPackageNotificationRecord.Channel.MOCK + assert record.send_status == RegulatoryInfoPackageNotificationRecord.SendStatus.PENDING diff --git a/tests/test_regulatory_info_package_package_generate.py b/tests/test_regulatory_info_package_package_generate.py new file mode 100644 index 0000000..c1331a9 --- /dev/null +++ b/tests/test_regulatory_info_package_package_generate.py @@ -0,0 +1,281 @@ +import json +import pytest +from docx import Document +from pathlib import Path + +from django.conf import settings +from django.utils import timezone +from review_agent.models import Conversation, RegulatoryInfoPackageBatch +from review_agent.regulatory_info_package.services.field_merge import merge_fields +from review_agent.regulatory_info_package.services import package_generate +from review_agent.regulatory_info_package.services.package_generate import generate_package_documents +from review_agent.regulatory_info_package.services.template_config import load_template_config + + +pytestmark = pytest.mark.django_db + + +def test_template_config_uses_clean_internal_templates(): + config = load_template_config() + source_dir = Path(config["source_dir"]) + + assert source_dir == settings.BASE_DIR / "review_agent" / "regulatory_info_package" / "templates" / "clean" + assert source_dir.exists() + assert len(config["templates"]) == 6 + assert all((source_dir / item["source_file"]).exists() for item in config["templates"]) + + +def test_clean_templates_expose_stable_fill_placeholders(): + config = load_template_config() + source_dir = Path(config["source_dir"]) + expected_by_code = { + "ch1_2_directory": {"{{product_name}}"}, + "ch1_4_application_form": {"{{product_name}}", "{{applicant_name}}"}, + "ch1_5_product_list": {"{{product_name}}"}, + "ch1_11_1_standards": {"{{product_name}}"}, + "ch1_11_5_authenticity": {"{{product_name}}"}, + "ch1_11_6_conformity": {"{{product_name}}"}, + } + + for item in config["templates"]: + document = Document(source_dir / item["source_file"]) + text = _document_text(document) + for placeholder in expected_by_code[item["code"]]: + assert placeholder in text + + +def test_directory_template_includes_page_numbers(): + config = load_template_config() + source_dir = Path(config["source_dir"]) + item = next(template for template in config["templates"] if template["code"] == "ch1_2_directory") + document = Document(source_dir / item["source_file"]) + page_numbers = [row.cells[4].text.strip() for row in document.tables[0].rows[1:]] + + assert page_numbers == ["1", "1", "1", "1", "1", "1"] + + +def test_application_form_template_uses_real_checkbox_symbols(): + config = load_template_config() + source_dir = Path(config["source_dir"]) + item = next(template for template in config["templates"] if template["code"] == "ch1_4_application_form") + text = _document_text(Document(source_dir / item["source_file"])) + + assert "{{复选框}}" not in text + assert "{{}}" not in text + assert "☐" in text + assert "☑" in text + + +def test_generate_package_documents_creates_six_results(django_user_model, tmp_path): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = RegulatoryInfoPackageBatch.objects.create( + conversation=conversation, + user=user, + batch_no="RIP-20260610154000-abcdef", + work_dir=str(tmp_path), + ) + merged, _summary = merge_fields({"product_name": {"value": "测试产品", "label": "产品名称"}}, {}) + + results = generate_package_documents(batch, load_template_config(), merged) + + assert len(results) == 6 + assert all(result.status in {"success", "fallback_success"} for result in results), [ + (result.template_code, result.status, result.error_message) for result in results + ] + assert all(result.path for result in results) + + +def test_directory_is_generated_last_with_real_page_counts(django_user_model, tmp_path, monkeypatch): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = RegulatoryInfoPackageBatch.objects.create( + conversation=conversation, + user=user, + batch_no="RIP-20260610154010-abcdef", + work_dir=str(tmp_path), + ) + merged, _summary = merge_fields({"product_name": {"value": "测试产品", "label": "产品名称"}}, {}) + page_counts = { + "CH1.4 申请表.docx": 3, + "CH1.5 产品列表.docx": 5, + "CH1.11.1 符合标准的清单.docx": 2, + "CH1.11.5 真实性声明.docx": 4, + "CH1.11.6 符合性声明.docx": 6, + } + counted_files = [] + + def fake_count(path): + counted_files.append(Path(path).name) + return page_counts[Path(path).name] + + monkeypatch.setattr(package_generate, "count_document_pages", fake_count, raising=False) + + results = generate_package_documents(batch, load_template_config(), merged) + + assert results[-1].template_code == "ch1_2_directory" + assert set(counted_files) == set(page_counts) + directory = Document(results[-1].path) + directory_pages = {row.cells[0].text.strip(): row.cells[4].text.strip() for row in directory.tables[0].rows[1:]} + assert directory_pages == { + "CH1.2": "1", + "CH1.4": "3", + "CH1.5": "5", + "CH1.11.1": "2", + "CH1.11.5": "4", + "CH1.11.6": "6", + } + + +def test_generated_docx_does_not_add_prefill_or_audit_blocks(django_user_model, tmp_path): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = RegulatoryInfoPackageBatch.objects.create( + conversation=conversation, + user=user, + batch_no="RIP-20260610154100-abcdef", + work_dir=str(tmp_path), + ) + merged, _summary = merge_fields({"product_name": {"value": "测试产品", "label": "产品名称"}}, {}) + + results = generate_package_documents(batch, load_template_config(), merged) + for result in results: + document = Document(result.path) + text = _document_text(document) + + assert "预生成版" not in text + assert "预生成字段" not in text + assert "component_table" not in text + assert '"header"' not in text + assert "测试产品" in text + + +def test_generated_docx_replaces_sample_case_content(django_user_model, tmp_path): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = RegulatoryInfoPackageBatch.objects.create( + conversation=conversation, + user=user, + batch_no="RIP-20260610154200-abcdef", + work_dir=str(tmp_path), + ) + merged, _summary = merge_fields( + { + "product_name": {"value": "测试产品", "label": "产品名称"}, + "package_specification": {"value": "24人份/盒;48人份/盒", "label": "包装规格"}, + }, + {}, + ) + + results = generate_package_documents(batch, load_template_config(), merged) + docx_results = [result for result in results if result.actual_format == "docx"] + for result in docx_results: + document = Document(result.path) + text = "\n".join(paragraph.text for paragraph in document.paragraphs) + for table in document.tables: + for row in table.rows: + text += "\n" + "\t".join(cell.text for cell in row.cells) + assert "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒" not in text + product_list = next(result for result in results if result.template_code == "ch1_5_product_list") + product_doc = Document(product_list.path) + table = product_doc.tables[0] + assert table.rows[1].cells[0].text == "24人份/盒" + assert table.rows[1].cells[1].text == "/" + assert "6018003102" not in "\n".join(cell.text for row in table.rows for cell in row.cells) + + +def test_generated_docs_fill_clean_template_body(django_user_model, tmp_path): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = RegulatoryInfoPackageBatch.objects.create( + conversation=conversation, + user=user, + batch_no="RIP-20260610154300-abcdef", + work_dir=str(tmp_path), + ) + merged, _summary = merge_fields( + { + "product_name": {"value": "甲型流感病毒核酸检测试剂盒", "label": "产品名称"}, + "applicant_name": {"value": "星河医疗科技有限公司", "label": "申请人名称"}, + "package_specification": {"value": "24人份/盒;48人份/盒", "label": "包装规格"}, + "standard_no": {"value": "GB/T 29791.1-2013", "label": "标准号"}, + }, + {}, + ) + + results = generate_package_documents(batch, load_template_config(), merged) + + for code in ["ch1_2_directory", "ch1_4_application_form", "ch1_11_5_authenticity", "ch1_11_6_conformity"]: + result = next(item for item in results if item.template_code == code) + text = _document_text(Document(result.path)) + assert "甲型流感病毒核酸检测试剂盒" in text + if code == "ch1_4_application_form": + assert "星河医疗科技有限公司" in text + assert "{{" not in text + assert "}}" not in text + + today = timezone.localdate().strftime("%Y年%m月%d日") + for code in ["ch1_11_1_standards", "ch1_11_5_authenticity", "ch1_11_6_conformity"]: + result = next(item for item in results if item.template_code == code) + text = _document_text(Document(result.path)) + assert today in text + assert "xxxx年xx月xx日" not in text + assert "星河医疗科技有限公司" not in text + + product_list = next(item for item in results if item.template_code == "ch1_5_product_list") + product_text = _document_text(Document(product_list.path)) + assert "24人份/盒" in product_text + assert "48人份/盒" in product_text + + +def test_product_list_uses_component_table_from_instruction(django_user_model, tmp_path): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = RegulatoryInfoPackageBatch.objects.create( + conversation=conversation, + user=user, + batch_no="RIP-20260610154400-abcdef", + work_dir=str(tmp_path), + ) + component_payload = { + "header": ["组分", "主要组成成分", "规格(24人份/盒)", "规格(48人份/盒)"], + "rows": [ + ["PCR反应液 I", "逆转录酶、Taq酶", "840μL/管×1管", "840μL/管×2管"], + ["阳性对照品", "含目的片段的假病毒", "600μL/管×2管", "1200μL/管×2管"], + ], + } + merged, _summary = merge_fields( + { + "product_name": {"value": "新型冠状病毒核酸检测试剂盒", "label": "产品名称"}, + "package_specification": {"value": "24人份/盒;48人份/盒", "label": "包装规格"}, + "component_table": { + "value": json.dumps(component_payload, ensure_ascii=False), + "label": "主要组成成分", + }, + "component_notes": { + "value": "注:不同批号试剂盒中各组分不得互换使用。", + "label": "主要组成成分备注", + }, + }, + {}, + ) + + results = generate_package_documents(batch, load_template_config(), merged) + product_list = next(result for result in results if result.template_code == "ch1_5_product_list") + document = Document(product_list.path) + text = _document_text(document) + + assert "PCR反应液 I" in text + assert "840μL/管×1管" in text + assert "840μL/管×2管" in text + assert "注:不同批号试剂盒中各组分不得互换使用。" in text + assert "RSV&MP" not in text + assert "6018003102" not in text + + +def _document_text(document: Document) -> str: + text = "\n".join(paragraph.text for paragraph in document.paragraphs) + for table in document.tables: + for row in table.rows: + text += "\n" + "\t".join(cell.text for cell in row.cells) + return text diff --git a/tests/test_regulatory_info_package_summary.py b/tests/test_regulatory_info_package_summary.py new file mode 100644 index 0000000..6575a96 --- /dev/null +++ b/tests/test_regulatory_info_package_summary.py @@ -0,0 +1,13 @@ +from review_agent.regulatory_info_package.services.summary import build_assistant_summary + + +def test_build_assistant_summary_puts_zip_first(): + exports = [ + {"file_name": "CH1.4 申请表.docx", "download_url": "/docx"}, + {"file_name": "第1章 监管信息(预生成版).zip", "download_url": "/zip", "export_type": "zip"}, + ] + + summary = build_assistant_summary(batch_no="RIP-1", exports=exports, failed_files=[]) + + assert summary.index("第1章 监管信息(预生成版).zip") < summary.index("CH1.4 申请表.docx") + diff --git a/tests/test_regulatory_info_package_template_config.py b/tests/test_regulatory_info_package_template_config.py new file mode 100644 index 0000000..ed4e132 --- /dev/null +++ b/tests/test_regulatory_info_package_template_config.py @@ -0,0 +1,46 @@ +from pathlib import Path + +import pytest + +from review_agent.regulatory_info_package.constants import DEFAULT_ZIP_NAME +from review_agent.regulatory_info_package.services.template_config import ( + compute_config_hash, + load_template_config, + validate_template_config, +) + + +def test_template_config_loads_six_templates(): + config = load_template_config() + + assert config["version"] == "regulatory_info_package_templates_v1" + assert config["zip_name"] == DEFAULT_ZIP_NAME + assert len(config["templates"]) == 6 + assert {template["code"] for template in config["templates"]} == { + "ch1_2_directory", + "ch1_4_application_form", + "ch1_5_product_list", + "ch1_11_1_standards", + "ch1_11_5_authenticity", + "ch1_11_6_conformity", + } + assert validate_template_config(config) == [] + assert compute_config_hash() + + +def test_template_config_rejects_duplicate_codes(): + config = load_template_config() + config["templates"].append(dict(config["templates"][0])) + + errors = validate_template_config(config) + + assert any("重复" in error for error in errors) + + +def test_template_config_sources_exist(): + config = load_template_config() + source_dir = Path(config["source_dir"]) + + assert source_dir.exists() + for template in config["templates"]: + assert (source_dir / template["source_file"]).exists() diff --git a/tests/test_regulatory_info_package_traceability.py b/tests/test_regulatory_info_package_traceability.py new file mode 100644 index 0000000..e80fac8 --- /dev/null +++ b/tests/test_regulatory_info_package_traceability.py @@ -0,0 +1,28 @@ +from pathlib import Path + +from openpyxl import load_workbook + +from review_agent.regulatory_info_package.schemas import MergedField +from review_agent.regulatory_info_package.services.traceability_export import save_traceability_exports + + +def test_save_traceability_exports_writes_excel_and_json(tmp_path): + fields = { + "product_name": MergedField( + key="product_name", + label="产品名称", + value="测试产品", + source="rule", + evidence="说明书", + confidence=0.9, + ) + } + + excel_path, json_path = save_traceability_exports(tmp_path, fields) + + assert excel_path.name == "traceability.xlsx" + assert json_path.name == "traceability.json" + assert json_path.exists() + workbook = load_workbook(excel_path) + assert workbook.active["A1"].value == "target_file" + diff --git a/tests/test_regulatory_info_package_trigger.py b/tests/test_regulatory_info_package_trigger.py new file mode 100644 index 0000000..2402e0a --- /dev/null +++ b/tests/test_regulatory_info_package_trigger.py @@ -0,0 +1,19 @@ +import pytest + +from review_agent.models import Conversation +from review_agent.skill_router import route_message_intent + + +pytestmark = pytest.mark.django_db + + +def test_fixed_keyword_routes_to_regulatory_info_package(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + route = route_message_intent(conversation, "请根据说明书生成第1章监管信息") + + assert route.action == "regulatory_info_package" + assert route.workflow_type == "regulatory_info_package" + assert route.starts_regulatory_info_package is True + diff --git a/tests/test_regulatory_info_package_views.py b/tests/test_regulatory_info_package_views.py new file mode 100644 index 0000000..9836eae --- /dev/null +++ b/tests/test_regulatory_info_package_views.py @@ -0,0 +1,140 @@ +from pathlib import Path + +import pytest + +from review_agent.models import ( + Conversation, + ExportedSummaryFile, + RegulatoryInfoPackageBatch, + WorkflowNodeRun, +) + + +pytestmark = pytest.mark.django_db + + +def test_regulatory_info_package_export_download_checks_owner(client, django_user_model, tmp_path): + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + conversation = Conversation.objects.create(user=owner, title="会话") + batch = RegulatoryInfoPackageBatch.objects.create( + conversation=conversation, + user=owner, + batch_no="RIP-20260610153300-abcdef", + ) + path = tmp_path / "第1章 监管信息(预生成版).zip" + path.write_bytes(b"zip-content") + exported = ExportedSummaryFile.objects.create( + batch=None, + workflow_type="regulatory_info_package", + workflow_batch_id=batch.pk, + export_category="regulatory_info_package", + export_type=ExportedSummaryFile.ExportType.ZIP, + file_name=path.name, + storage_path=str(path), + ) + + client.force_login(other) + denied = client.get(f"/api/review-agent/file-summary/exports/{exported.pk}/download/") + assert denied.status_code == 404 + + client.force_login(owner) + allowed = client.get(f"/api/review-agent/file-summary/exports/{exported.pk}/download/") + assert allowed.status_code == 200 + assert allowed["Content-Type"] == "application/zip" + + +@pytest.mark.parametrize( + ("file_name", "export_type", "expected"), + [ + ("CH1.9 产品申报前沟通的说明.doc", ExportedSummaryFile.ExportType.WORD, "application/msword"), + ( + "CH1.4 申请表.docx", + ExportedSummaryFile.ExportType.WORD, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ), + ("第1章 监管信息(预生成版).zip", ExportedSummaryFile.ExportType.ZIP, "application/zip"), + ], +) +def test_regulatory_info_package_download_mime_by_extension( + client, + django_user_model, + tmp_path, + file_name, + export_type, + expected, +): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = RegulatoryInfoPackageBatch.objects.create( + conversation=conversation, + user=user, + batch_no=f"RIP-20260610153400-{Path(file_name).suffix[1:] or 'zip'}", + ) + path = tmp_path / file_name + path.write_bytes(b"content") + exported = ExportedSummaryFile.objects.create( + batch=None, + workflow_type="regulatory_info_package", + workflow_batch_id=batch.pk, + export_category="generated_document", + export_type=export_type, + file_name=file_name, + storage_path=str(path), + ) + client.force_login(user) + + response = client.get(f"/api/review-agent/file-summary/exports/{exported.pk}/download/") + + assert response.status_code == 200 + assert response["Content-Type"] == expected + + +def test_regulatory_info_package_status_returns_nodes_and_zip_first(client, django_user_model, tmp_path): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = RegulatoryInfoPackageBatch.objects.create( + conversation=conversation, + user=user, + batch_no="RIP-20260610153500-abcdef", + status=RegulatoryInfoPackageBatch.Status.SUCCESS, + ) + WorkflowNodeRun.objects.create( + workflow_type="regulatory_info_package", + workflow_batch_id=batch.pk, + node_group="regulatory_info_package", + node_code="zip_export", + node_name="打包下载", + status=WorkflowNodeRun.Status.SUCCESS, + progress=100, + ) + doc = tmp_path / "CH1.4 申请表.docx" + zip_file = tmp_path / "第1章 监管信息(预生成版).zip" + doc.write_bytes(b"doc") + zip_file.write_bytes(b"zip") + ExportedSummaryFile.objects.create( + batch=None, + workflow_type="regulatory_info_package", + workflow_batch_id=batch.pk, + export_category="generated_document", + export_type=ExportedSummaryFile.ExportType.WORD, + file_name=doc.name, + storage_path=str(doc), + ) + ExportedSummaryFile.objects.create( + batch=None, + workflow_type="regulatory_info_package", + workflow_batch_id=batch.pk, + export_category="regulatory_info_package", + export_type=ExportedSummaryFile.ExportType.ZIP, + file_name=zip_file.name, + storage_path=str(zip_file), + ) + client.force_login(user) + + response = client.get(f"/api/review-agent/regulatory-info-package/{batch.pk}/status/") + + payload = response.json() + assert payload["batch"]["workflow_type"] == "regulatory_info_package" + assert payload["nodes"][0]["node_code"] == "zip_export" + assert payload["exports"][0]["export_type"] == "zip" diff --git a/tests/test_regulatory_info_package_workflow.py b/tests/test_regulatory_info_package_workflow.py new file mode 100644 index 0000000..4f2b699 --- /dev/null +++ b/tests/test_regulatory_info_package_workflow.py @@ -0,0 +1,92 @@ +from pathlib import Path + +import pytest + +from review_agent.models import Conversation, FileAttachment, Message, RegulatoryInfoPackageBatch, WorkflowNodeRun +from review_agent.regulatory_info_package.constants import ( + REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS, + WORKFLOW_TYPE, +) +from review_agent.regulatory_info_package.workflow import ( + create_regulatory_info_package_batch, + start_regulatory_info_package_workflow, +) + + +pytestmark = pytest.mark.django_db + + +def test_create_regulatory_info_package_batch_initializes_nodes(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + batch = create_regulatory_info_package_batch(conversation=conversation, user=user) + + assert batch.batch_no.startswith("RIP-") + assert batch.work_dir + nodes = WorkflowNodeRun.objects.filter( + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=batch.pk, + ).order_by("id") + assert [node.node_code for node in nodes] == [ + code for code, _name, _group in REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS + ] + + +def test_create_regulatory_info_package_batch_is_node_idempotent(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = create_regulatory_info_package_batch(conversation=conversation, user=user) + + create_regulatory_info_package_batch(conversation=conversation, user=user, existing_batch=batch) + + assert WorkflowNodeRun.objects.filter( + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=batch.pk, + ).count() == len(REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS) + + +def test_empty_workflow_skeleton_completes(django_user_model, settings): + settings.REGULATORY_INFO_PACKAGE_ASYNC = False + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = create_regulatory_info_package_batch(conversation=conversation, user=user) + + start_regulatory_info_package_workflow(batch, async_run=False) + batch.refresh_from_db() + + assert batch.status == RegulatoryInfoPackageBatch.Status.SUCCESS + assert WorkflowNodeRun.objects.filter( + workflow_type=WORKFLOW_TYPE, + workflow_batch_id=batch.pk, + status=WorkflowNodeRun.Status.SUCCESS, + ).count() == len(REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS) + + +def test_completed_workflow_appends_download_summary_message(django_user_model, settings): + settings.REGULATORY_INFO_PACKAGE_ASYNC = False + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + trigger = Message.objects.create(conversation=conversation, role=Message.Role.USER, content="根据说明书生成第1章监管信息") + source = Path("docs/0.原始材料/目标产品说明书.docx").resolve() + attachment = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="目标产品说明书.docx", + storage_path=str(source), + file_size=source.stat().st_size, + ) + batch = create_regulatory_info_package_batch( + conversation=conversation, + user=user, + trigger_message=trigger, + source_attachment=attachment, + source_file_name=attachment.original_name, + source_storage_path=attachment.storage_path, + ) + + start_regulatory_info_package_workflow(batch, async_run=False) + + message = conversation.messages.filter(role=Message.Role.ASSISTANT, content__contains=batch.batch_no).latest("id") + assert "第1章 监管信息(预生成版).zip" in message.content + assert "/api/review-agent/file-summary/exports/" in message.content diff --git a/tests/test_regulatory_info_package_zip.py b/tests/test_regulatory_info_package_zip.py new file mode 100644 index 0000000..60e9235 --- /dev/null +++ b/tests/test_regulatory_info_package_zip.py @@ -0,0 +1,22 @@ +import zipfile + +from review_agent.regulatory_info_package.schemas import GeneratedFileResult +from review_agent.regulatory_info_package.services.zip_export import create_zip_package + + +def test_create_zip_package_includes_only_success_files(tmp_path): + success = tmp_path / "ok.docx" + failed = tmp_path / "bad.docx" + success.write_bytes(b"ok") + failed.write_bytes(b"bad") + + zip_path = create_zip_package( + tmp_path, + [ + GeneratedFileResult("ok", "ok.docx", "docx", "docx", "success", path=str(success)), + GeneratedFileResult("bad", "bad.docx", "docx", "docx", "failed", path=str(failed)), + ], + ) + + with zipfile.ZipFile(zip_path) as archive: + assert archive.namelist() == ["ok.docx"] diff --git a/tests/test_regulatory_llm_review.py b/tests/test_regulatory_llm_review.py new file mode 100644 index 0000000..b67c762 --- /dev/null +++ b/tests/test_regulatory_llm_review.py @@ -0,0 +1,111 @@ +import json + +from review_agent.regulatory_review.services.llm_review import review_condition_fields, review_workflow_payload + + +def test_review_condition_fields_selects_more_complete_llm_product_name(): + def completion(messages, temperature=0.0): + return json.dumps( + { + "fields": { + "产品名称": "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒 (荧光PCR法)", + "型号规格": "24人份/盒", + } + }, + ensure_ascii=False, + ) + + result = review_condition_fields( + text="产品名称:呼吸道合胞病毒、肺炎支原体核酸检测试剂盒\n(荧光PCR法)\n型号规格:24人份/盒", + rule_fields={"产品名称": "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒", "型号规格": "24人份/盒"}, + file_context="申请表.txt", + completion_func=completion, + ) + + assert result["selected_fields"]["产品名称"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒 (荧光PCR法)" + assert result["selected_sources"]["产品名称"] == "llm" + assert result["selected_sources"]["型号规格"] == "rule" + + +def test_review_condition_fields_falls_back_when_llm_returns_chapter_title(): + def completion(messages, temperature=0.0): + return json.dumps({"fields": {"产品名称": "第1章 监管信息"}}, ensure_ascii=False) + + result = review_condition_fields( + text="产品名称:甲胎蛋白检测试剂盒", + rule_fields={"产品名称": "甲胎蛋白检测试剂盒"}, + file_context="申请表.txt", + completion_func=completion, + ) + + assert result["selected_fields"]["产品名称"] == "甲胎蛋白检测试剂盒" + assert result["selected_sources"]["产品名称"] == "rule" + + +def test_review_condition_fields_rejects_garbled_llm_product_name(): + def completion(messages, temperature=0.0): + return json.dumps({"fields": {"产品名称": "呼吸道合胞病毒、 �肺炎支原体核酸检测试剂盒 (荧光PCR法)"}}, ensure_ascii=False) + + result = review_condition_fields( + text="呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)", + rule_fields={"产品名称": "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)"}, + file_context="产品列表.txt", + completion_func=completion, + ) + + assert result["selected_fields"]["产品名称"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)" + assert result["selected_sources"]["产品名称"] == "rule" + + +def test_review_workflow_payload_handles_timeout_without_raising(): + def completion(messages, temperature=0.0): + raise TimeoutError("The read operation timed out") + + result = review_workflow_payload( + stage="completeness_check", + payload={"findings": []}, + completion_func=completion, + ) + + assert result["status"] == "failed" + assert result["stage"] == "completeness_check" + assert "timed out" in result["error_message"] + + +def test_review_workflow_payload_retries_timeout_before_success(settings): + settings.REGULATORY_LLM_REVIEW_RETRY_DELAY_SECONDS = 0 + attempts = {"count": 0} + + def completion(messages, temperature=0.0): + attempts["count"] += 1 + if attempts["count"] < 3: + raise TimeoutError("The read operation timed out") + return json.dumps({"reviewed": True}) + + result = review_workflow_payload( + stage="completeness_check", + payload={"findings": []}, + completion_func=completion, + ) + + assert attempts["count"] == 3 + assert result["status"] == "success" + assert result["result"]["reviewed"] is True + + +def test_review_workflow_payload_passes_configured_timeout(settings): + settings.REGULATORY_LLM_REVIEW_RETRY_DELAY_SECONDS = 0 + settings.REGULATORY_LLM_REVIEW_TIMEOUT_SECONDS = 7 + observed = {} + + def completion(messages, temperature=0.0, timeout=None): + observed["timeout"] = timeout + return json.dumps({"reviewed": True}) + + review_workflow_payload( + stage="completeness_check", + payload={"findings": []}, + completion_func=completion, + ) + + assert observed["timeout"] == 7 diff --git a/tests/test_regulatory_models.py b/tests/test_regulatory_models.py new file mode 100644 index 0000000..9ebd390 --- /dev/null +++ b/tests/test_regulatory_models.py @@ -0,0 +1,137 @@ +import pytest + +from review_agent.models import ( + Conversation, + ExportedSummaryFile, + FileSummaryBatch, + Message, + RegulatoryArtifact, + RegulatoryIssue, + RegulatoryNotificationRecord, + RegulatoryReviewBatch, + RegulatoryRuleVersion, + WorkflowEvent, + WorkflowNodeRun, +) + + +pytestmark = pytest.mark.django_db + + +def test_regulatory_models_store_batch_issue_artifact_and_notification(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="法规核查") + trigger = Message.objects.create( + conversation=conversation, + role=Message.Role.USER, + content="请做NMPA法规核查", + ) + summary_batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-READY", + status=FileSummaryBatch.Status.SUCCESS, + ) + rule_version = RegulatoryRuleVersion.objects.create( + code="nmpa_ivd_registration_v1", + name="NMPA IVD 注册资料 Demo 规则", + yaml_path="review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml", + yaml_hash="abc123", + rag_collection="nmpa_ivd_registration_v1", + rag_index_version="idx-1", + rag_index_hash="hash-1", + status=RegulatoryRuleVersion.Status.ACTIVE, + ) + + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + trigger_message=trigger, + source_summary_batch=summary_batch, + rule_version=rule_version, + batch_no="RR-202606070001-abcdef", + ) + issue = RegulatoryIssue.objects.create( + batch=batch, + rule_code="registration_test_report", + category=RegulatoryIssue.Category.COMPLETENESS, + severity=RegulatoryIssue.Severity.BLOCKING, + title="缺少注册检验报告", + suggestion="请补充注册检验报告并复核。", + evidence={"matched_files": []}, + citations=[{"source": "法规.doc", "text": "注册检验报告"}], + ) + artifact = RegulatoryArtifact.objects.create( + batch=batch, + artifact_type=RegulatoryArtifact.ArtifactType.JSON, + name="结果包", + storage_path="media/regulatory_review/result.json", + content_hash="hash", + ) + notification = RegulatoryNotificationRecord.objects.create( + batch=batch, + channel=RegulatoryNotificationRecord.Channel.MOCK, + target="todo-plan", + payload={"issue_id": issue.pk}, + ) + + assert batch.status == RegulatoryReviewBatch.Status.PENDING + assert batch.source_summary_batch == summary_batch + assert issue.status == RegulatoryIssue.Status.OPEN + assert artifact.artifact_type == RegulatoryArtifact.ArtifactType.JSON + assert notification.status == RegulatoryNotificationRecord.Status.PENDING + + +def test_generic_workflow_fields_support_file_summary_and_regulatory_batches(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary_batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-GENERIC", + ) + regulatory_batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary_batch, + batch_no="RR-GENERIC", + ) + + file_node = WorkflowNodeRun.objects.create( + batch=summary_batch, + workflow_type="file_summary", + workflow_batch_id=summary_batch.pk, + node_group="file_summary", + node_code="inventory", + node_name="文件扫描", + ) + regulatory_node = WorkflowNodeRun.objects.create( + workflow_type="regulatory_review", + workflow_batch_id=regulatory_batch.pk, + node_group="regulatory_review", + node_code="prepare", + node_name="准备", + ) + event = WorkflowEvent.objects.create( + batch=summary_batch, + workflow_type="regulatory_review", + workflow_batch_id=regulatory_batch.pk, + conversation=conversation, + event_type="workflow_created", + payload={"batch_no": regulatory_batch.batch_no}, + ) + exported = ExportedSummaryFile.objects.create( + batch=summary_batch, + workflow_type="regulatory_review", + workflow_batch_id=regulatory_batch.pk, + export_category="result_package", + export_type=ExportedSummaryFile.ExportType.JSON, + file_name="result.json", + storage_path="media/regulatory_review/result.json", + ) + + assert file_node.batch == summary_batch + assert regulatory_node.batch is None + assert regulatory_node.workflow_batch_id == regulatory_batch.pk + assert event.conversation == conversation + assert exported.export_type == ExportedSummaryFile.ExportType.JSON diff --git a/tests/test_regulatory_notification.py b/tests/test_regulatory_notification.py new file mode 100644 index 0000000..a800be6 --- /dev/null +++ b/tests/test_regulatory_notification.py @@ -0,0 +1,109 @@ +import pytest + +from review_agent.models import ( + Conversation, + FileSummaryBatch, + RegulatoryIssue, + RegulatoryNotificationRecord, + RegulatoryReviewBatch, +) +from review_agent.regulatory_review.services.export import build_markdown_report, build_result_payload +from review_agent.regulatory_review.services.feishu_notifier import create_mock_notifications +from review_agent.regulatory_review.workflow import RegulatoryWorkflowExecutor + + +pytestmark = pytest.mark.django_db + + +def test_create_mock_notifications_for_medium_and_above(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-NOTIFY", + status=FileSummaryBatch.Status.SUCCESS, + ) + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-NOTIFY", + ) + high = RegulatoryIssue.objects.create( + batch=batch, + rule_code="attachment4_1_2_application_form", + category=RegulatoryIssue.Category.COMPLETENESS, + severity=RegulatoryIssue.Severity.HIGH, + title="缺少申请表", + ) + RegulatoryIssue.objects.create( + batch=batch, + rule_code="info", + category=RegulatoryIssue.Category.RAG, + severity=RegulatoryIssue.Severity.INFO, + title="提示项", + ) + + records = create_mock_notifications(batch) + + assert len(records) == 1 + assert records[0].channel == RegulatoryNotificationRecord.Channel.MOCK + assert records[0].status == RegulatoryNotificationRecord.Status.SENT + assert records[0].payload["issue_id"] == high.pk + + +def test_notification_records_enter_reports(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-NOTIFY", + status=FileSummaryBatch.Status.SUCCESS, + ) + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-NOTIFY", + ) + RegulatoryNotificationRecord.objects.create( + batch=batch, + channel=RegulatoryNotificationRecord.Channel.MOCK, + target="法规整改负责人", + status=RegulatoryNotificationRecord.Status.SENT, + payload={"title": "缺少申请表", "severity": "high"}, + ) + + assert "通知记录" in build_markdown_report(batch) + assert build_result_payload(batch)["notifications"][0]["channel"] == "mock" + + +def test_regulatory_completion_notification_uses_dispatcher(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-NOTIFY-DISPATCH", + status=FileSummaryBatch.Status.SUCCESS, + ) + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-NOTIFY-DISPATCH", + status=RegulatoryReviewBatch.Status.SUCCESS, + ) + calls = [] + + monkeypatch.setattr( + "review_agent.regulatory_review.workflow.dispatch_workflow_notification", + lambda context: calls.append(context), + ) + + RegulatoryWorkflowExecutor(batch)._dispatch_completion_notification() + + assert calls + assert calls[0].workflow_type == "regulatory_review" diff --git a/tests/test_regulatory_rag.py b/tests/test_regulatory_rag.py new file mode 100644 index 0000000..79930c4 --- /dev/null +++ b/tests/test_regulatory_rag.py @@ -0,0 +1,229 @@ +import sys + +import pytest + +from review_agent.regulatory_review.services.rag_citation import ( + RagIndexUnavailable, + retrieve_citations, +) +from review_agent.regulatory_review.services.rag_embedding import SiliconFlowEmbeddingProvider +from review_agent.regulatory_review.services.rag_index import chunk_text +from review_agent.regulatory_review.services.rag_index import collect_source_chunks +from review_agent.regulatory_review.services.rag_index import build_chroma_index + + +def test_siliconflow_embedding_provider_posts_expected_payload(monkeypatch): + calls = [] + + class FakeResponse: + def raise_for_status(self): + return None + + def json(self): + return {"data": [{"embedding": [0.1, 0.2]}, {"embedding": [0.3, 0.4]}]} + + def fake_post(url, headers, json, timeout): + calls.append({"url": url, "headers": headers, "json": json, "timeout": timeout}) + return FakeResponse() + + monkeypatch.setattr("review_agent.regulatory_review.services.rag_embedding.httpx.post", fake_post) + + provider = SiliconFlowEmbeddingProvider( + api_key="secret", + base_url="https://api.siliconflow.cn/v1", + model="Qwen/Qwen3-Embedding-4B", + dimensions=1024, + ) + + assert provider.embed(["法规依据", "注册检验报告"]) == [[0.1, 0.2], [0.3, 0.4]] + assert calls[0]["url"] == "https://api.siliconflow.cn/v1/embeddings" + assert calls[0]["headers"]["Authorization"] == "Bearer secret" + assert calls[0]["json"]["model"] == "Qwen/Qwen3-Embedding-4B" + assert calls[0]["json"]["dimensions"] == 1024 + + +def test_chunk_text_preserves_source_metadata(): + chunks = chunk_text( + "第一段法规内容。\n" * 20, + source="法规.doc", + chunk_size=30, + overlap=5, + ) + + assert len(chunks) > 1 + assert chunks[0].metadata["source"] == "法规.doc" + assert chunks[0].text + + +def test_retrieve_citations_returns_placeholder_when_no_hits(): + class EmptyCollection: + def query(self, query_embeddings, n_results): + return {"documents": [[]], "metadatas": [[]], "distances": [[]]} + + citations = retrieve_citations( + "注册检验报告", + embedding_provider=lambda texts: [[0.1, 0.2]], + collection=EmptyCollection(), + ) + + assert citations[0]["source"] == "原文依据待补充" + + +def test_retrieve_citations_raises_when_index_missing(settings, tmp_path): + settings.REGULATORY_RAG_CHROMA_PATH = tmp_path / "missing" + + with pytest.raises(RagIndexUnavailable): + retrieve_citations("注册检验报告", embedding_provider=lambda texts: [[0.1]]) + + +def test_collect_source_chunks_requires_attachment4_extraction(monkeypatch, tmp_path): + source_dir = tmp_path / "sources" + source_dir.mkdir() + attachment4 = source_dir / "附件 4 体外诊断试剂注册申报资料要求及说明.doc" + attachment4.write_bytes(b"legacy-doc") + + def fail_extract(path): + raise RuntimeError("无法通过 LibreOffice 转换法规 .doc 材料") + + monkeypatch.setattr("review_agent.regulatory_review.services.rag_index.extract_text_from_path", fail_extract) + + with pytest.raises(RuntimeError, match="附件 4"): + collect_source_chunks(source_dir) + + +def test_collect_source_chunks_excludes_demo_agent_materials(monkeypatch, tmp_path): + source_dir = tmp_path / "sources" + source_dir.mkdir() + demo_dir = source_dir / "【模拟题二】试剂盒临床注册文件准备与审核Agent" + demo_dir.mkdir() + (demo_dir / "【模拟题二】试剂盒临床注册文件准备与审核Agent.md").write_text("题目材料", encoding="utf-8") + (source_dir / "【模拟题二】试剂盒临床注册文件准备与审核Agent.docx").write_bytes(b"demo") + real_source = source_dir / "附件 4 体外诊断试剂注册申报资料要求及说明.doc" + real_source.write_bytes(b"rule") + + def fake_extract(path): + return "附件4 正文" if path == real_source else "不应被抽取" + + monkeypatch.setattr("review_agent.regulatory_review.services.rag_index.extract_text_from_path", fake_extract) + + chunks = collect_source_chunks(source_dir) + + assert chunks + assert all("模拟题二" not in chunk.metadata["source"] for chunk in chunks) + + +def test_build_chroma_index_reset_recreates_collection_without_deleting_index_dir(settings, monkeypatch, tmp_path): + settings.MEDIA_ROOT = tmp_path + persist_path = tmp_path / "chroma" + persist_path.mkdir() + stale_file = persist_path / "chroma.sqlite3" + stale_file.write_text("stale", encoding="utf-8") + source_dir = tmp_path / "sources" + source_dir.mkdir() + (source_dir / "rule.md").write_text("注册检验报告要求", encoding="utf-8") + client_states = [] + deleted_collections = [] + + class FakeCollection: + def upsert(self, **kwargs): + return None + + class FakeClient: + def __init__(self, path): + client_states.append({"path": path, "stale_exists": stale_file.exists()}) + + def delete_collection(self, name): + deleted_collections.append(name) + + def get_or_create_collection(self, name): + return FakeCollection() + + class FakeSharedSystemClient: + @staticmethod + def clear_system_cache(): + client_states.append({"path": "cache-cleared", "stale_exists": stale_file.exists()}) + + monkeypatch.setitem(sys.modules, "chromadb", type("FakeChromaModule", (), {"PersistentClient": FakeClient})) + monkeypatch.setitem( + sys.modules, + "chromadb.api.shared_system_client", + type("FakeSharedSystemClientModule", (), {"SharedSystemClient": FakeSharedSystemClient}), + ) + + count = build_chroma_index( + source_dir=source_dir, + embedding_provider=lambda texts: [[0.1, 0.2] for _ in texts], + persist_path=persist_path, + collection_name="test", + reset=True, + ) + + assert count == 1 + assert client_states == [ + {"path": str(persist_path), "stale_exists": True}, + {"path": "cache-cleared", "stale_exists": True}, + {"path": str(persist_path), "stale_exists": True}, + ] + assert stale_file.exists() + assert deleted_collections == ["test"] + + +def test_build_chroma_index_reset_clears_bad_index_dir_after_chroma_cache_reset(settings, monkeypatch, tmp_path): + settings.MEDIA_ROOT = tmp_path + persist_path = tmp_path / "chroma" + persist_path.mkdir() + stale_file = persist_path / "chroma.sqlite3" + stale_file.write_text("stale", encoding="utf-8") + source_dir = tmp_path / "sources" + source_dir.mkdir() + (source_dir / "rule.md").write_text("注册检验报告要求", encoding="utf-8") + events = [] + + class FakeCollection: + def upsert(self, **kwargs): + return None + + class BrokenThenFreshClient: + attempts = 0 + + def __init__(self, path): + BrokenThenFreshClient.attempts += 1 + events.append(("client", BrokenThenFreshClient.attempts, stale_file.exists())) + if BrokenThenFreshClient.attempts == 1: + raise ValueError("Could not connect to tenant default_tenant") + + def get_or_create_collection(self, name): + return FakeCollection() + + class FakeSharedSystemClient: + @staticmethod + def clear_system_cache(): + events.append(("clear_cache", stale_file.exists())) + + fake_chromadb = type( + "FakeChromaModule", + (), + {"PersistentClient": BrokenThenFreshClient}, + ) + monkeypatch.setitem(sys.modules, "chromadb", fake_chromadb) + monkeypatch.setitem( + sys.modules, + "chromadb.api.shared_system_client", + type("FakeSharedSystemClientModule", (), {"SharedSystemClient": FakeSharedSystemClient}), + ) + + count = build_chroma_index( + source_dir=source_dir, + embedding_provider=lambda texts: [[0.1, 0.2] for _ in texts], + persist_path=persist_path, + collection_name="test", + reset=True, + ) + + assert count == 1 + assert events == [ + ("client", 1, True), + ("clear_cache", True), + ("client", 2, False), + ] + assert not stale_file.exists() diff --git a/tests/test_regulatory_rectification.py b/tests/test_regulatory_rectification.py new file mode 100644 index 0000000..831c1fc --- /dev/null +++ b/tests/test_regulatory_rectification.py @@ -0,0 +1,133 @@ +import json + +import pytest +from django.urls import reverse + +from review_agent.models import ( + Conversation, + FileSummaryBatch, + FileSummaryItem, + RegulatoryArtifact, + RegulatoryIssue, + RegulatoryReviewBatch, +) +from review_agent.regulatory_review.services.export import build_markdown_report, build_result_payload +from review_agent.regulatory_review.services.rectification_review import review_missing_issues + + +pytestmark = pytest.mark.django_db + + +def _make_review_batch(user): + conversation = Conversation.objects.create(user=user, title="会话") + original_summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-ORIGINAL", + status=FileSummaryBatch.Status.SUCCESS, + ) + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=original_summary, + batch_no="RR-ORIGINAL", + status=RegulatoryReviewBatch.Status.SUCCESS, + ) + return conversation, original_summary, batch + + +def test_start_full_package_review_creates_new_traceable_batch(client, settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + settings.REGULATORY_REVIEW_ASYNC = False + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation, _original_summary, original_batch = _make_review_batch(user) + new_summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-NEW", + status=FileSummaryBatch.Status.SUCCESS, + ) + client.force_login(user) + + response = client.post( + reverse("regulatory_review_start_full_review", args=[original_batch.pk]), + data=json.dumps({"file_summary_batch_id": new_summary.pk}), + content_type="application/json", + ) + + assert response.status_code == 200 + new_batch = RegulatoryReviewBatch.objects.exclude(pk=original_batch.pk).get() + assert new_batch.source_summary_batch == new_summary + assert new_batch.condition_json["source_review_batch_id"] == original_batch.pk + assert new_batch.condition_json["regenerated_from"]["batch_no"] == "RR-ORIGINAL" + + +def test_review_missing_issues_updates_status_and_writes_record(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation, _original_summary, batch = _make_review_batch(user) + issue = RegulatoryIssue.objects.create( + batch=batch, + rule_code="attachment4_5_3_label", + category=RegulatoryIssue.Category.COMPLETENESS, + severity=RegulatoryIssue.Severity.HIGH, + title="缺少标签样稿", + suggestion="请补充标签样稿。", + ) + supplement = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-SUPPLEMENT", + status=FileSummaryBatch.Status.SUCCESS, + ) + FileSummaryItem.objects.create( + batch=supplement, + file_index=1, + directory_level="5. 产品说明书和标签样稿", + file_name="标签样稿.pdf", + file_type="pdf", + relative_path="5.3 标签样稿/标签样稿.pdf", + storage_path="x/label.pdf", + ) + + record = review_missing_issues(batch=batch, issue_ids=[issue.pk], file_summary_batch=supplement) + + issue.refresh_from_db() + assert issue.status == RegulatoryIssue.Status.REVIEW_PASSED + assert record["items"][0]["status"] == "review_passed" + assert RegulatoryArtifact.objects.filter(batch=batch, name__startswith="review_record").exists() + + +def test_missing_issue_review_endpoint_and_report_output(client, settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation, _original_summary, batch = _make_review_batch(user) + issue = RegulatoryIssue.objects.create( + batch=batch, + rule_code="attachment4_6_quality_system", + category=RegulatoryIssue.Category.COMPLETENESS, + severity=RegulatoryIssue.Severity.HIGH, + title="缺少质量管理体系文件", + suggestion="请补充质量管理体系文件。", + ) + supplement = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-SUPPLEMENT", + status=FileSummaryBatch.Status.SUCCESS, + ) + client.force_login(user) + + response = client.post( + reverse("regulatory_review_review_issues", args=[batch.pk]), + data=json.dumps({"issue_ids": [issue.pk], "file_summary_batch_id": supplement.pk}), + content_type="application/json", + ) + + issue.refresh_from_db() + payload = build_result_payload(batch) + markdown = build_markdown_report(batch) + assert response.status_code == 200 + assert issue.status == RegulatoryIssue.Status.REVIEW_FAILED + assert payload["review_records"][0]["file_summary_batch_no"] == "FS-SUPPLEMENT" + assert "复核记录" in markdown diff --git a/tests/test_regulatory_risk_assess.py b/tests/test_regulatory_risk_assess.py new file mode 100644 index 0000000..7a5f1e9 --- /dev/null +++ b/tests/test_regulatory_risk_assess.py @@ -0,0 +1,35 @@ +import pytest + +from review_agent.models import Conversation, FileSummaryBatch, RegulatoryIssue, RegulatoryReviewBatch +from review_agent.regulatory_review.schemas import Finding +from review_agent.regulatory_review.services.risk_assess import persist_findings + + +pytestmark = pytest.mark.django_db + + +def test_persist_findings_deduplicates_and_updates_risk_summary(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-OK") + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-RISK", + ) + finding = Finding( + rule_code="registration_test_report", + category="completeness", + severity="blocking", + title="缺少注册检验报告", + suggestion="请补充注册检验报告并复核。", + citations=[{"source": "法规.doc", "text": "注册检验报告"}], + ) + + issues = persist_findings(batch, [finding, finding]) + + batch.refresh_from_db() + assert len(issues) == 1 + assert RegulatoryIssue.objects.count() == 1 + assert batch.risk_summary["blocking"] == 1 diff --git a/tests/test_regulatory_rule_loader.py b/tests/test_regulatory_rule_loader.py new file mode 100644 index 0000000..b200b67 --- /dev/null +++ b/tests/test_regulatory_rule_loader.py @@ -0,0 +1,93 @@ +from pathlib import Path +import json + +import pytest +from django.core.management import call_command + +from review_agent.models import RegulatoryRuleVersion +from review_agent.regulatory_review.services.rule_loader import ( + DEFAULT_RULE_CODE, + check_rule_version, + compute_file_sha256, + load_rule_file, +) + + +pytestmark = pytest.mark.django_db + + +def test_load_rule_file_reads_demo_requirements(): + rule_set = load_rule_file() + + codes = {item["code"] for item in rule_set["requirements"]} + assert rule_set["code"] == DEFAULT_RULE_CODE + assert "product_technical_requirements" in codes + assert "instructions_for_use" in codes + assert "registration_test_report" in codes + assert "clinical_evaluation" in codes + assert "essential_principles_checklist" in codes + + +def test_load_rule_file_covers_attachment4_outline(): + rule_set = load_rule_file() + requirements = rule_set["requirements"] + outline = json.loads(Path("tests/fixtures/regulatory/attachment4_outline.json").read_text(encoding="utf-8")) + + for chapter in outline: + chapter_rule = next( + item for item in requirements if item["title"] == chapter["title"] and item.get("attachment4_code") == chapter["code"] + ) + assert chapter_rule["attachment4_code"] == chapter["code"] + assert chapter_rule["severity"] == "high" + assert chapter_rule["citation_query"] + for child in chapter["children"]: + child_rule = next( + item + for item in requirements + if item["title"] == child and str(item.get("attachment4_code", "")).startswith(f"{chapter['code']}.") + ) + assert child_rule["rule_id"] + assert child_rule["file_keywords"] + assert child_rule["severity"] in {"blocking", "high", "medium"} + assert child_rule["citation_query"] + + +def test_compute_file_sha256_changes_when_file_changes(tmp_path): + path = tmp_path / "rule.yaml" + path.write_text("code: demo\n", encoding="utf-8") + first = compute_file_sha256(path) + path.write_text("code: demo2\n", encoding="utf-8") + + assert compute_file_sha256(path) != first + + +def test_check_rule_version_creates_missing_db_record(): + result = check_rule_version(update_missing=True) + + record = RegulatoryRuleVersion.objects.get(code=DEFAULT_RULE_CODE) + assert result.status == "created" + assert result.current_hash == record.yaml_hash + assert record.rag_collection == "nmpa_ivd_registration_v1" + + +def test_check_rule_version_reports_hash_mismatch_without_overwriting(): + created = check_rule_version(update_missing=True) + record = RegulatoryRuleVersion.objects.get(code=DEFAULT_RULE_CODE) + record.yaml_hash = "stale" + record.save(update_fields=["yaml_hash"]) + + result = check_rule_version(update_missing=False) + record.refresh_from_db() + + assert result.status == "mismatch" + assert result.database_hash == "stale" + assert result.current_hash == created.current_hash + assert record.yaml_hash == "stale" + + +def test_regulatory_rules_check_command_reports_status(capsys): + call_command("regulatory_rules_check") + + captured = capsys.readouterr() + assert DEFAULT_RULE_CODE in captured.out + assert "created" in captured.out or "ok" in captured.out diff --git a/tests/test_regulatory_storage.py b/tests/test_regulatory_storage.py new file mode 100644 index 0000000..4fdb7bb --- /dev/null +++ b/tests/test_regulatory_storage.py @@ -0,0 +1,26 @@ +import pytest + +from review_agent.models import Conversation, FileSummaryBatch, RegulatoryReviewBatch +from review_agent.regulatory_review.storage import save_artifact + + +pytestmark = pytest.mark.django_db + + +def test_save_artifact_writes_file_and_records_hash(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-OK") + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-ART", + ) + + artifact = save_artifact(batch, name="raw.json", content='{"ok": true}', artifact_type="json") + + assert artifact.content_hash + assert artifact.storage_path.endswith("raw.json") + assert (tmp_path / "regulatory_review" / "work" / "RR-ART" / "raw.json").exists() diff --git a/tests/test_regulatory_structure.py b/tests/test_regulatory_structure.py new file mode 100644 index 0000000..ac571ff --- /dev/null +++ b/tests/test_regulatory_structure.py @@ -0,0 +1,26 @@ +from review_agent.regulatory_review.services.rule_loader import load_rule_file +from review_agent.regulatory_review.services.structure_check import run_structure_check + + +def test_structure_check_reports_missing_instruction_sections(): + document_texts = { + "说明书.docx": "产品名称:甲胎蛋白检测试剂盒\n样本要求:血清样本\n有效期:12个月" + } + + findings = run_structure_check(document_texts, load_rule_file()) + + assert any(finding.rule_code == "instructions_for_use:储存条件" for finding in findings) + assert all("样本要求" not in finding.title for finding in findings) + + +def test_structure_check_reports_missing_attachment4_outline_heading(): + document_texts = { + "申报资料目录.txt": "1. 监管信息\n1.2 申请表\n2. 综述资料\n3. 非临床资料\n" + } + + findings = run_structure_check(document_texts, load_rule_file()) + + missing = next(finding for finding in findings if finding.rule_code == "attachment4_4_clinical_evaluation") + assert missing.category == "structure" + assert missing.title == "申报资料目录缺少4临床评价资料章节" + assert missing.evidence["expected_title"] == "临床评价资料" diff --git a/tests/test_regulatory_text_extract.py b/tests/test_regulatory_text_extract.py new file mode 100644 index 0000000..a9effe0 --- /dev/null +++ b/tests/test_regulatory_text_extract.py @@ -0,0 +1,61 @@ +from pathlib import Path + +from review_agent.regulatory_review.services.text_extract import extract_text + + +def test_extract_text_reads_plain_text(tmp_path): + path = tmp_path / "说明书.txt" + path.write_text("产品名称:甲胎蛋白检测试剂盒\n储存条件:2-8℃", encoding="utf-8") + + result = extract_text(path) + + assert "甲胎蛋白" in result.text + assert result.status == "success" + assert result.content_hash + + +def test_extract_text_keeps_wrapped_product_name(tmp_path): + path = tmp_path / "申请表.txt" + path.write_text( + "产品名称:呼吸道合胞病毒、肺炎支原体核酸检测试剂盒\n" + "(荧光PCR法)\n" + "型号规格:24人份/盒\n", + encoding="utf-8", + ) + + result = extract_text(path) + + assert result.field_candidates["产品名称"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒 (荧光PCR法)" + assert result.field_candidates["型号规格"] == "24人份/盒" + + +def test_extract_text_reports_unsupported_file(tmp_path): + path = tmp_path / "image.png" + path.write_bytes(b"png") + + result = extract_text(path) + + assert result.status == "unsupported" + assert result.text == "" + + +def test_extract_text_from_docx_preserves_table_text(tmp_path): + from docx import Document + + path = tmp_path / "说明书.docx" + document = Document() + document.add_paragraph("【主要组成成分】") + table = document.add_table(rows=2, cols=2) + table.rows[0].cells[0].text = "组分" + table.rows[0].cells[1].text = "数量" + table.rows[1].cells[0].text = "PCR反应液" + table.rows[1].cells[1].text = "1管" + document.add_paragraph("【储存条件及有效期】") + document.add_paragraph("-20±5℃保存,有效期12个月。") + document.save(path) + + result = extract_text(path) + + assert result.status == "success" + assert "组分\t数量" in result.text + assert result.text.index("PCR反应液") < result.text.index("【储存条件及有效期】") diff --git a/tests/test_regulatory_views.py b/tests/test_regulatory_views.py new file mode 100644 index 0000000..4b507b2 --- /dev/null +++ b/tests/test_regulatory_views.py @@ -0,0 +1,136 @@ +import pytest +from django.urls import reverse + +from review_agent.models import Conversation, FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun + + +pytestmark = pytest.mark.django_db + + +def test_regulatory_batch_status_requires_owner(client, django_user_model): + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + conversation = Conversation.objects.create(user=owner, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=owner, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=owner, + source_summary_batch=summary, + batch_no="RR-STATUS", + ) + WorkflowNodeRun.objects.create( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + node_group="regulatory_review", + node_code="prepare", + node_name="准备", + progress=50, + ) + + client.force_login(other) + denied = client.get(reverse("regulatory_review_batch_status", args=[batch.pk])) + assert denied.status_code == 404 + + client.force_login(owner) + allowed = client.get(reverse("regulatory_review_batch_status", args=[batch.pk])) + assert allowed.status_code == 200 + payload = allowed.json() + assert payload["batch"]["workflow_type"] == "regulatory_review" + assert payload["batch"]["batch_no"] == "RR-STATUS" + assert payload["nodes"][0]["node_code"] == "prepare" + + +def test_regulatory_batch_status_exposes_condition_confirmation(client, django_user_model): + owner = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=owner, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=owner, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=owner, + source_summary_batch=summary, + batch_no="RR-WAIT", + status=RegulatoryReviewBatch.Status.WAITING_USER, + condition_json={ + "confirmed": False, + "candidates": { + "product_category": { + "label": "产品类别", + "input_type": "select", + "options": ["体外诊断试剂", "医疗器械", "其他"], + "suggested": "体外诊断试剂", + } + }, + }, + ) + client.force_login(owner) + + response = client.get(reverse("regulatory_review_batch_status", args=[batch.pk])) + + payload = response.json() + assert payload["batch"]["status"] == RegulatoryReviewBatch.Status.WAITING_USER + assert payload["condition_confirmation"]["batch_id"] == batch.pk + assert payload["condition_confirmation"]["candidates"]["product_category"]["suggested"] == "体外诊断试剂" + + +def test_regulatory_batch_status_refreshes_incomplete_condition_candidates( + client, settings, tmp_path, django_user_model +): + settings.MEDIA_ROOT = tmp_path + owner = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=owner, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=owner, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + product_name="第1章 监管信息", + ) + application = tmp_path / "application.txt" + application.write_text( + "卡尤迪生物科技宜兴有限公司申请境内第三类体外诊断试剂" + "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)产品注册。", + encoding="utf-8", + ) + from review_agent.models import FileSummaryItem + + FileSummaryItem.objects.create( + batch=summary, + file_index=1, + directory_level="第1章 监管信息", + file_name="符合标准的清单.txt", + file_type="txt", + relative_path="第1章 监管信息/符合标准的清单.txt", + storage_path=str(application), + ) + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=owner, + source_summary_batch=summary, + batch_no="RR-WAIT-EMPTY", + status=RegulatoryReviewBatch.Status.WAITING_USER, + condition_json={ + "confirmed": False, + "candidates": { + "product_category": {"suggested": "其他"}, + "product_name": {"suggested": ""}, + }, + }, + ) + client.force_login(owner) + + response = client.get(reverse("regulatory_review_batch_status", args=[batch.pk])) + + payload = response.json() + candidates = payload["condition_confirmation"]["candidates"] + assert candidates["product_category"]["suggested"] == "体外诊断试剂" + assert candidates["product_name"]["suggested"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)" diff --git a/tests/test_regulatory_workflow.py b/tests/test_regulatory_workflow.py new file mode 100644 index 0000000..18da71b --- /dev/null +++ b/tests/test_regulatory_workflow.py @@ -0,0 +1,502 @@ +import logging + +import pytest + +from review_agent.models import ( + Conversation, + ExportedSummaryFile, + FileAttachment, + FileSummaryBatch, + FileSummaryItem, + Message, + RegulatoryIssue, + RegulatoryArtifact, + RegulatoryReviewBatch, + WorkflowEvent, + WorkflowNodeRun, +) +from review_agent.regulatory_review.workflow import ( + NODE_DEFINITIONS, + RegulatoryWorkflowExecutor, + create_regulatory_review_batch, + find_latest_successful_summary_batch, + start_regulatory_review_workflow, +) +from review_agent.services import stream_message +from review_agent.skill_router import SkillRoute, route_message_intent + + +pytestmark = pytest.mark.django_db + + +def test_rule_router_starts_regulatory_review_for_nmpa_keywords(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + monkeypatch.setattr( + "review_agent.skill_router._route_with_llm", + lambda conversation, content, attachments: (_ for _ in ()).throw(ValueError("fallback")), + ) + + route = route_message_intent(conversation, "请做NMPA核查和风险预警") + + assert route.action == "regulatory_review" + assert route.workflow_type == "regulatory_review" + assert route.starts_regulatory_review + + +def test_find_latest_successful_summary_batch_ignores_failed_batches(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + success = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-FAILED", + status=FileSummaryBatch.Status.FAILED, + ) + + assert find_latest_successful_summary_batch(conversation) == success + + +def test_create_regulatory_review_batch_initializes_nodes(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + message = Message.objects.create(conversation=conversation, role=Message.Role.USER, content="法规核查") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + + batch = create_regulatory_review_batch( + conversation=conversation, + user=user, + trigger_message=message, + source_summary_batch=summary, + ) + + assert batch.status == RegulatoryReviewBatch.Status.PENDING + assert WorkflowNodeRun.objects.filter( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + ).count() == len(NODE_DEFINITIONS) + assert WorkflowEvent.objects.filter( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + event_type="workflow_created", + ).exists() + + +def test_start_regulatory_review_workflow_runs_synchronously(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + batch = create_regulatory_review_batch( + conversation=conversation, + user=user, + source_summary_batch=summary, + ) + batch.condition_json = {"confirmed": True, "confirmed_conditions": {"product_category": "体外诊断试剂"}} + batch.save(update_fields=["condition_json"]) + + start_regulatory_review_workflow(batch, async_run=False) + + batch.refresh_from_db() + assert batch.status == RegulatoryReviewBatch.Status.SUCCESS + assert WorkflowEvent.objects.filter( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + event_type="workflow_completed", + ).exists() + + +def test_workflow_continues_when_llm_review_times_out(monkeypatch, settings, django_user_model): + settings.REGULATORY_LLM_REVIEW_ALLOW_TEST_CALLS = True + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + batch = create_regulatory_review_batch( + conversation=conversation, + user=user, + source_summary_batch=summary, + ) + batch.condition_json = {"confirmed": True, "confirmed_conditions": {"product_category": "体外诊断试剂"}} + batch.save(update_fields=["condition_json"]) + monkeypatch.setattr( + "review_agent.regulatory_review.services.llm_review.generate_completion", + lambda messages, temperature=0.0: (_ for _ in ()).throw(TimeoutError("The read operation timed out")), + ) + + start_regulatory_review_workflow(batch, async_run=False) + + batch.refresh_from_db() + assert batch.status == RegulatoryReviewBatch.Status.SUCCESS + assert batch.error_message == "" + + +def test_regulatory_workflow_logs_node_and_method_details(caplog, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + batch = create_regulatory_review_batch( + conversation=conversation, + user=user, + source_summary_batch=summary, + ) + batch.condition_json = {"confirmed": True, "confirmed_conditions": {"product_category": "体外诊断试剂"}} + batch.save(update_fields=["condition_json"]) + + with caplog.at_level(logging.INFO, logger="review_agent.regulatory_review.workflow"): + start_regulatory_review_workflow(batch, async_run=False) + + messages = [record.getMessage() for record in caplog.records] + assert any("法规核查工作流开始" in message and batch.batch_no in message for message in messages) + assert any("节点开始" in message and "完整性核查" in message for message in messages) + assert any("方法执行" in message and "run_completeness_check" in message for message in messages) + assert any("节点完成" in message and "完整性核查" in message for message in messages) + + +def test_stream_message_prompts_for_summary_when_missing(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + monkeypatch.setattr( + "review_agent.services.route_message_intent", + lambda conversation, content: SkillRoute( + action="regulatory_review", + workflow_type="regulatory_review", + confidence=0.9, + ), + ) + + frames = list(stream_message(conversation, "请做法规核查")) + + joined = "".join(frames) + assert "请先在当前对话右侧上传需要核查的文件或压缩包" in joined + assert "我会先自动汇总再继续法规核查" in joined + assert not RegulatoryReviewBatch.objects.exists() + + +def test_stream_message_auto_runs_summary_before_regulatory_review( + monkeypatch, settings, tmp_path, django_user_model +): + settings.MEDIA_ROOT = tmp_path + settings.REGULATORY_REVIEW_ASYNC = False + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + attachment_path = tmp_path / "application.txt" + attachment_path.write_text("产品名称:甲胎蛋白检测试剂盒", encoding="utf-8") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="application.txt", + storage_path=str(attachment_path), + file_size=attachment_path.stat().st_size, + is_active=True, + ) + monkeypatch.setattr( + "review_agent.services.route_message_intent", + lambda conversation, content: SkillRoute( + action="regulatory_review", + workflow_type="regulatory_review", + confidence=0.9, + ), + ) + + def finish_summary(batch, async_run=True): + batch.status = FileSummaryBatch.Status.SUCCESS + batch.save(update_fields=["status"]) + + monkeypatch.setattr("review_agent.services.start_file_summary_workflow", finish_summary) + + frames = list(stream_message(conversation, "进行第一章NMPA 法规核查")) + joined = "".join(frames) + + assert "\"workflow_type\": \"file_summary\"" in joined + assert "\"workflow_type\": \"regulatory_review\"" in joined + assert "已先启动文件目录与页数自动汇总工作流" in joined + assert FileSummaryBatch.objects.filter(conversation=conversation, status=FileSummaryBatch.Status.SUCCESS).exists() + regulatory = RegulatoryReviewBatch.objects.get(conversation=conversation) + assert regulatory.condition_json["rule_scope"]["attachment4_chapter"] == "1" + + +def test_stream_message_starts_regulatory_workflow(monkeypatch, settings, django_user_model): + settings.REGULATORY_REVIEW_ASYNC = False + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + monkeypatch.setattr( + "review_agent.services.route_message_intent", + lambda conversation, content: SkillRoute( + action="regulatory_review", + workflow_type="regulatory_review", + confidence=0.9, + ), + ) + + frames = list(stream_message(conversation, "请做法规核查")) + + joined = "".join(frames) + assert "workflow_started" in joined + assert "\"workflow_type\": \"regulatory_review\"" in joined + assert RegulatoryReviewBatch.objects.filter(conversation=conversation).exists() + + +def test_stream_message_records_attachment4_chapter_scope(monkeypatch, settings, django_user_model): + settings.REGULATORY_REVIEW_ASYNC = False + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + monkeypatch.setattr( + "review_agent.services.route_message_intent", + lambda conversation, content: SkillRoute( + action="regulatory_review", + workflow_type="regulatory_review", + confidence=0.9, + ), + ) + + list(stream_message(conversation, "请做第一章 NMPA 法规核查")) + + batch = RegulatoryReviewBatch.objects.get(conversation=conversation) + assert batch.condition_json["rule_scope"]["attachment4_chapter"] == "1" + assert batch.condition_json["rule_scope"]["label"] == "第1章 监管信息" + + +def test_workflow_chapter_scope_only_checks_selected_attachment4_chapter(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + settings.REGULATORY_REVIEW_ASYNC = False + settings.REGULATORY_RAG_CHROMA_PATH = tmp_path / "missing-rag" + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + batch = create_regulatory_review_batch( + conversation=conversation, + user=user, + source_summary_batch=summary, + ) + batch.condition_json = { + "confirmed": True, + "confirmed_conditions": {"product_category": "体外诊断试剂"}, + "rule_scope": {"attachment4_chapter": "1", "label": "第1章 监管信息"}, + } + batch.save(update_fields=["condition_json"]) + + start_regulatory_review_workflow(batch, async_run=False) + + issue_codes = list(RegulatoryIssue.objects.filter(batch=batch).values_list("rule_code", flat=True)) + assert issue_codes + assert all(code.startswith("attachment4_1") for code in issue_codes) + assert not any(code.startswith("attachment4_2") for code in issue_codes) + + +def test_workflow_generates_issues_exports_and_assistant_summary(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + settings.REGULATORY_REVIEW_ASYNC = False + settings.REGULATORY_RAG_CHROMA_PATH = tmp_path / "missing-rag" + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + ifu_path = tmp_path / "ifu.txt" + ifu_path.write_text("产品名称:甲胎蛋白检测试剂盒\n样本要求:血清\n有效期:12个月", encoding="utf-8") + FileSummaryItem.objects.create( + batch=summary, + file_index=1, + file_name="说明书.txt", + file_type="txt", + relative_path="说明书.txt", + storage_path=str(ifu_path), + ) + batch = create_regulatory_review_batch( + conversation=conversation, + user=user, + source_summary_batch=summary, + ) + batch.condition_json = {"confirmed": True, "confirmed_conditions": {"product_category": "体外诊断试剂"}} + batch.save(update_fields=["condition_json"]) + + start_regulatory_review_workflow(batch, async_run=False) + + batch.refresh_from_db() + assert batch.status == RegulatoryReviewBatch.Status.SUCCESS + assert RegulatoryIssue.objects.filter(batch=batch, severity="blocking").exists() + assert ExportedSummaryFile.objects.filter( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + ).count() == 3 + assert RegulatoryArtifact.objects.filter(batch=batch, name="text_extract_status.json").exists() + assert RegulatoryArtifact.objects.filter(batch=batch, name="rag_result_json.json").exists() + assert conversation.messages.filter(role=Message.Role.ASSISTANT, content__contains="已完成 NMPA").exists() + + +def test_workflow_records_llm_review_artifacts_for_review_nodes( + monkeypatch, settings, tmp_path, django_user_model +): + settings.MEDIA_ROOT = tmp_path + settings.REGULATORY_REVIEW_ASYNC = False + settings.REGULATORY_RAG_CHROMA_PATH = tmp_path / "missing-rag" + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + ifu_path = tmp_path / "ifu.txt" + ifu_path.write_text("产品名称:甲胎蛋白检测试剂盒\n型号规格:20人份/盒", encoding="utf-8") + FileSummaryItem.objects.create( + batch=summary, + file_index=1, + file_name="说明书.txt", + file_type="txt", + relative_path="说明书.txt", + storage_path=str(ifu_path), + ) + batch = create_regulatory_review_batch( + conversation=conversation, + user=user, + source_summary_batch=summary, + ) + batch.condition_json = {"confirmed": True, "confirmed_conditions": {"product_category": "体外诊断试剂"}} + batch.save(update_fields=["condition_json"]) + + monkeypatch.setattr( + "review_agent.regulatory_review.workflow.review_workflow_payload", + lambda stage, payload: {"status": "success", "stage": stage, "result": {"reviewed": True}, "error_message": ""}, + ) + + start_regulatory_review_workflow(batch, async_run=False) + + artifact_names = set(RegulatoryArtifact.objects.filter(batch=batch).values_list("name", flat=True)) + assert "llm_review_completeness_check.json" in artifact_names + assert "llm_review_text_extract.json" in artifact_names + assert "llm_review_structure_check.json" in artifact_names + assert "llm_review_consistency_check.json" in artifact_names + assert "llm_review_risk_assess.json" in artifact_names + + +def test_workflow_progress_uses_processed_file_counts(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + for index, name in enumerate(["注册信息.txt", "说明书.txt", "综述.txt"], start=1): + path = tmp_path / name + path.write_text(f"产品名称:甲胎蛋白检测试剂盒\n文件:{name}", encoding="utf-8") + FileSummaryItem.objects.create( + batch=summary, + file_index=index, + file_name=name, + file_type="txt", + relative_path=name, + storage_path=str(path), + ) + batch = create_regulatory_review_batch( + conversation=conversation, + user=user, + source_summary_batch=summary, + ) + node = WorkflowNodeRun.objects.get( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + node_code="text_extract", + ) + executor = RegulatoryWorkflowExecutor(batch) + + texts = executor._extract_source_texts(node) + + node.refresh_from_db() + assert len(texts) == 3 + assert node.progress == 95 + assert "文本抽取 3/3" in node.message + assert "综述.txt" in node.message + assert WorkflowEvent.objects.filter( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + event_type="node_progress", + payload__node_code="text_extract", + payload__processed=3, + payload__total=3, + ).exists() + + +def test_review_services_emit_actual_workload_progress_callbacks(django_user_model): + from review_agent.regulatory_review.services.completeness_check import run_completeness_check + from review_agent.regulatory_review.services.consistency_check import FIELDS, run_consistency_check + from review_agent.regulatory_review.services.structure_check import run_structure_check + + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + rule_set = { + "requirements": [ + {"code": "r1", "title": "注册信息", "type": "required", "file_keywords": ["注册信息"]}, + {"code": "r2", "title": "说明书", "type": "required", "file_keywords": ["说明书"]}, + ] + } + completeness_updates = [] + structure_updates = [] + consistency_updates = [] + + run_completeness_check(summary, rule_set, progress_callback=completeness_updates.append) + run_structure_check({"注册信息.txt": "注册信息"}, rule_set, progress_callback=structure_updates.append) + run_consistency_check({"注册信息.txt": "产品名称:A"}, progress_callback=consistency_updates.append) + + assert completeness_updates[-1]["processed"] == 2 + assert completeness_updates[-1]["total"] == 2 + assert completeness_updates[-1]["label"] == "说明书" + assert structure_updates[-1]["processed"] == 2 + assert structure_updates[-1]["total"] == 2 + assert consistency_updates[-1]["processed"] == len(FIELDS) + assert consistency_updates[-1]["total"] == len(FIELDS)