From 84e045f5aba7acf5cbab4d2a584deef9a7065966 Mon Sep 17 00:00:00 2001 From: bruce Date: Thu, 4 Jun 2026 23:42:37 +0800 Subject: [PATCH 001/111] =?UTF-8?q?feat(demo):=20=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E5=AE=A1=E6=A0=B8=E6=99=BA=E8=83=BD=E4=BD=93=E6=BC=94?= =?UTF-8?q?=E7=A4=BA=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 10 + README.md | 20 + config/__init__.py | 1 + config/asgi.py | 8 + config/settings.py | 104 +++ config/urls.py | 27 + config/wsgi.py | 8 + manage.py | 19 + requirements.txt | 1 + review_agent/__init__.py | 1 + review_agent/apps.py | 7 + review_agent/llm.py | 79 +++ review_agent/migrations/0001_initial.py | 78 +++ review_agent/migrations/__init__.py | 1 + review_agent/models.py | 44 ++ review_agent/services.py | 90 +++ review_agent/views.py | 47 ++ static/css/login.css | 741 ++++++++++++++++++++ static/js/app.js | 59 ++ templates/base.html | 14 + templates/home.html | 151 ++++ templates/registration/login.html | 30 + templates/registration/password_change.html | 31 + 23 files changed, 1571 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/settings.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 manage.py create mode 100644 requirements.txt create mode 100644 review_agent/__init__.py create mode 100644 review_agent/apps.py create mode 100644 review_agent/llm.py create mode 100644 review_agent/migrations/0001_initial.py create mode 100644 review_agent/migrations/__init__.py create mode 100644 review_agent/models.py create mode 100644 review_agent/services.py create mode 100644 review_agent/views.py create mode 100644 static/css/login.css create mode 100644 static/js/app.js create mode 100644 templates/base.html create mode 100644 templates/home.html create mode 100644 templates/registration/login.html create mode 100644 templates/registration/password_change.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30fdb94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +.venv/ +__pycache__/ +*.py[cod] +*.sqlite3 +db.sqlite3 +staticfiles/ +media/ +.pytest_cache/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..de78a58 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# DEMO-AGENT V2 + +V2 是一个重置后的最小 Django 项目,仅保留基础配置和登录页面。 + +## 本地运行 + +```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 +``` + +访问: + +- 登录页:http://127.0.0.1:8000/login/ +- 首页:http://127.0.0.1:8000/ +- 管理后台:http://127.0.0.1:8000/admin/ diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1 @@ + diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..d124534 --- /dev/null +++ b/config/asgi.py @@ -0,0 +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 new file mode 100644 index 0000000..11511b5 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,104 @@ +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + + +def load_env_file(file_path: Path) -> None: + """Loads a simple KEY=VALUE .env file into process env without extra deps.""" + + if not file_path.exists(): + return + + 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) + os.environ.setdefault(key.strip(), value.strip()) + + +load_env_file(BASE_DIR / ".env") + +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", + "127.0.0.1,localhost,testserver", + ).split(",") + if host.strip() +] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "review_agent", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "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", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.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 +USE_TZ = True + +STATIC_URL = "static/" +STATICFILES_DIRS = [BASE_DIR / "static"] + +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", "") diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..a80b0fb --- /dev/null +++ b/config/urls.py @@ -0,0 +1,27 @@ +from django.contrib import admin +from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView +from django.urls import path + +from review_agent.views import workspace + +urlpatterns = [ + path("", workspace, name="home"), + 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), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..25bf4d0 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +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/manage.py b/manage.py new file mode 100644 index 0000000..f1b0e57 --- /dev/null +++ b/manage.py @@ -0,0 +1,19 @@ +#!/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") + 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) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..af9b7e1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Django>=5.0,<6.0 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/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/llm.py b/review_agent/llm.py new file mode 100644 index 0000000..293fa14 --- /dev/null +++ b/review_agent/llm.py @@ -0,0 +1,79 @@ +import json +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.""" + + +def generate_reply(conversation, user_message: 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), + "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 build_messages(conversation, latest_user_message: str) -> list[dict[str, str]]: + """Builds system and conversation history messages for the provider call.""" + + messages = [{"role": "system", "content": system_prompt()}] + + 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/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/__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..46eba84 --- /dev/null +++ b/review_agent/models.py @@ -0,0 +1,44 @@ +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}" diff --git a/review_agent/services.py b/review_agent/services.py new file mode 100644 index 0000000..d3b5494 --- /dev/null +++ b/review_agent/services.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from django.db.models import Q, QuerySet +from django.utils import timezone + +from .llm import LLMConfigurationError, LLMRequestError, generate_reply +from .models import Conversation, Message + + +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(), + ) + + 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.""" + + return Message.objects.create( + conversation=conversation, + role=Message.Role.ASSISTANT, + content=content, + ) + + +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) + try: + reply_content = generate_reply(conversation, content) + 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 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] diff --git a/review_agent/views.py b/review_agent/views.py new file mode 100644 index 0000000..5432d28 --- /dev/null +++ b/review_agent/views.py @@ -0,0 +1,47 @@ +from django.contrib.auth.decorators import login_required +from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect, render +from django.views.decorators.http import require_http_methods + +from .services import create_conversation, get_conversation_for_user, list_conversations, send_message + + +@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"/?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"/?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() + + return render( + request, + "home.html", + { + "page_title": "审核智能体", + "search_query": search, + "conversations": conversations, + "current_conversation": current, + "messages": current.messages.all() if current else [], + }, + ) diff --git a/static/css/login.css b/static/css/login.css new file mode 100644 index 0000000..5ebdc3a --- /dev/null +++ b/static/css/login.css @@ -0,0 +1,741 @@ +: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; +} + +.workspace { + display: grid; + grid-template-columns: 296px minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 24px; + padding: 18px; + 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; +} + +.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; +} + +.history-list { + display: grid; + gap: 8px; +} + +.history-item { + display: grid; + gap: 4px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 14px; + color: var(--text); + text-decoration: none; + background: rgba(255, 255, 255, 0.82); +} + +.history-item.active, +.history-item:hover { + border-color: #cfdcf6; + background: #edf4ff; +} + +.history-title { + font-size: 14px; + font-weight: 600; +} + +.history-meta { + color: var(--muted); + font-size: 12px; +} + +.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: auto minmax(0, 1fr); + min-width: 0; + padding: 0; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 24px; + min-height: 60px; + 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 { + height: 60px; + padding: 0 20px; + border: 0; + background: transparent; + color: var(--muted); + cursor: pointer; + font: inherit; + font-weight: 600; + border-bottom: 2px solid transparent; +} + +.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; + background: #ffffff; + overflow: hidden; +} + +.chat-scroll { + min-height: 0; + padding: 32px min(6vw, 64px) 24px; + overflow-y: auto; +} + +.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; +} + +.message-bubble p { + margin: 0; +} + +.user-mark { + background: #dbe7ff; +} + +.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; +} + +.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); +} + +.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 { + display: none; +} + +.workspace[data-sidebar-state="collapsed"] .history-item { + place-items: center; + padding: 12px; +} + +.workspace[data-sidebar-state="collapsed"] .sidebar { + padding-left: 12px; + padding-right: 12px; +} + +@media (max-width: 980px) { + .workspace { + grid-template-columns: minmax(0, 1fr); + } + + .sidebar { + position: fixed; + inset: 0 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; + } + + .sidebar-toggle { + display: none; + } + + .topbar, + .chat-scroll, + .composer-wrap { + padding-left: 16px; + padding-right: 16px; + } + + .topbar { + align-items: flex-start; + flex-direction: column; + min-height: auto; + padding-top: 12px; + padding-bottom: 0; + } + + .topbar-right { + width: 100%; + justify-content: space-between; + } + + .conversation-header { + flex-direction: column; + } +} + +@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%; + } +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..cb5e451 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,59 @@ +(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"); + + if (!workspace) { + return; + } + + 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") === "collapsed") { + workspace.setAttribute("data-sidebar-state", "closed"); + } + } else if (workspace.getAttribute("data-sidebar-state") === "closed") { + workspace.setAttribute("data-sidebar-state", "open"); + } + } + + 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"); + } + }); + } + + window.addEventListener("resize", syncSidebarState); + syncSidebarState(); +})(); diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..a6064cd --- /dev/null +++ b/templates/base.html @@ -0,0 +1,14 @@ +{% load static %} + + + + + + {% block title %}DEMO-AGENT V2{% endblock %} + + + + {% block content %}{% endblock %} + {% block scripts %}{% endblock %} + + diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..28a73fc --- /dev/null +++ b/templates/home.html @@ -0,0 +1,151 @@ +{% 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 %} +
+
+

{{ message.content|linebreaksbr }}

+
+
+ {% endfor %} + {% else %} +
+

审核智能体

+

开始新的审核对话

+

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

+
+ {% endif %} +
+ +
+
+ {% csrf_token %} + + {% if current_conversation %} + + {% endif %} + + +
+
+ 法规核查 + 说明书审核 + 风险识别 +
+ +
+
+
+
+
+
+{% 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 %} From 7ab5aad938ff6799853f1ecf9c8dc12a1e3c1e3f Mon Sep 17 00:00:00 2001 From: bruce Date: Fri, 5 Jun 2026 00:11:53 +0800 Subject: [PATCH 002/111] =?UTF-8?q?feat(chat):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E5=9B=9E=E5=A4=8D=E4=B8=8E=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/urls.py | 3 +- ...拟题二】试剂盒临床注册文件准备与审核Agent.docx | Bin 0 -> 18601 bytes review_agent/llm.py | 51 +++ review_agent/services.py | 51 ++- review_agent/views.py | 32 +- static/css/login.css | 169 ++++++++- static/js/app.js | 346 ++++++++++++++++++ templates/home.html | 39 +- 8 files changed, 676 insertions(+), 15 deletions(-) create mode 100644 docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx diff --git a/config/urls.py b/config/urls.py index a80b0fb..ec39f6a 100644 --- a/config/urls.py +++ b/config/urls.py @@ -2,10 +2,11 @@ from django.contrib import admin from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView from django.urls import path -from review_agent.views import workspace +from review_agent.views import stream_chat, workspace urlpatterns = [ path("", workspace, name="home"), + path("chat/stream/", stream_chat, name="chat_stream"), path( "login/", LoginView.as_view( diff --git a/docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx b/docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx new file mode 100644 index 0000000000000000000000000000000000000000..6c81cb5e027a8afdaa115cb58a649a3af282a3e8 GIT binary patch literal 18601 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 OeE`2CG*;-3yZ;aR{W-w^ literal 0 HcmV?d00001 diff --git a/review_agent/llm.py b/review_agent/llm.py index 293fa14..6680f84 100644 --- a/review_agent/llm.py +++ b/review_agent/llm.py @@ -53,6 +53,57 @@ def generate_reply(conversation, user_message: str) -> str: raise LLMRequestError("模型接口返回格式不符合预期。") from exc +def stream_reply(conversation, user_message: 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), + "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 + payload = json.loads(data) + 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) -> list[dict[str, str]]: """Builds system and conversation history messages for the provider call.""" diff --git a/review_agent/services.py b/review_agent/services.py index d3b5494..43a3a2f 100644 --- a/review_agent/services.py +++ b/review_agent/services.py @@ -1,9 +1,11 @@ from __future__ import annotations +import json + from django.db.models import Q, QuerySet from django.utils import timezone -from .llm import LLMConfigurationError, LLMRequestError, generate_reply +from .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply from .models import Conversation, Message @@ -81,6 +83,47 @@ def send_message(conversation: Conversation, content: str) -> tuple[Message, Mes 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] = [] + + 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, + }, + ) + + try: + for chunk in stream_reply(conversation, content): + assistant_parts.append(chunk) + yield sse_event("chunk", {"delta": chunk}) + except (LLMConfigurationError, LLMRequestError) as exc: + fallback = f"模型调用失败:{exc}" + assistant_parts = [fallback] + 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.""" @@ -88,3 +131,9 @@ def build_conversation_title(content: str) -> str: if not normalized: return "新对话" return normalized[:24] + + +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/views.py b/review_agent/views.py index 5432d28..e384834 100644 --- a/review_agent/views.py +++ b/review_agent/views.py @@ -1,9 +1,15 @@ from django.contrib.auth.decorators import login_required -from django.http import HttpRequest, HttpResponse +from django.http import HttpRequest, HttpResponse, JsonResponse, StreamingHttpResponse from django.shortcuts import redirect, render from django.views.decorators.http import require_http_methods -from .services import create_conversation, get_conversation_for_user, list_conversations, send_message +from .services import ( + create_conversation, + get_conversation_for_user, + list_conversations, + send_message, + stream_message, +) @login_required @@ -45,3 +51,25 @@ def workspace(request: HttpRequest) -> HttpResponse: "messages": current.messages.all() if current else [], }, ) + + +@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 diff --git a/static/css/login.css b/static/css/login.css index 5ebdc3a..7f4f93f 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -470,14 +470,47 @@ input:focus { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; + height: calc(100vh - 60px); background: #ffffff; overflow: hidden; } -.chat-scroll { +.chat-scroll-wrap { + position: relative; min-height: 0; - padding: 32px min(6vw, 64px) 24px; + 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, @@ -533,10 +566,92 @@ input:focus { margin: 0; } +.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); @@ -604,6 +719,11 @@ input:focus { font-weight: 700; } +.send-button:disabled { + background: #a8bee8; + cursor: wait; +} + .sr-only { position: absolute; width: 1px; @@ -693,6 +813,18 @@ input:focus { .conversation-header { flex-direction: column; } + + .chat-stage { + height: calc(100vh - 88px); + } + + .chat-scroll { + padding-right: 72px; + } + + .node-rail { + right: 14px; + } } @media (max-width: 640px) { @@ -738,4 +870,37 @@ input:focus { .send-button { width: 100%; } + + .chat-shell { + padding: 0; + } + + .chat-stage { + height: calc(100vh - 126px); + } + + .chat-scroll { + padding-right: 44px; + } + + .node-rail { + right: 8px; + gap: 10px; + width: 20px; + } + +.node-dot { + width: 10px; + height: 10px; + } +} + +@keyframes pulse-caret { + 0%, + 100% { + opacity: 0.25; + } + 50% { + opacity: 1; + } } diff --git a/static/js/app.js b/static/js/app.js index cb5e451..1c3ee89 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -4,6 +4,14 @@ 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 nodeAnchors = []; if (!workspace) { return; @@ -32,6 +40,10 @@ } } + function refreshNodeAnchors() { + nodeAnchors = Array.prototype.slice.call(document.querySelectorAll(".node-anchor")); + } + if (sidebarToggle) { sidebarToggle.addEventListener("click", toggleSidebar); } @@ -54,6 +66,340 @@ }); } + 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 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 (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("p"); + text.innerHTML = nl2br(content); + bubble.appendChild(text); + + article.appendChild(avatar); + article.appendChild(bubble); + chatScroll.appendChild(article); + return { article: article, bubble: bubble, text: text }; + } + + 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[href*="conversation=' + 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("a"); + item.className = "history-item active"; + item.href = "/?conversation=" + conversationId; + item.innerHTML = + '' + + escapeHtml(encodedTitle) + + '' + + meta + + ""; + list.prepend(item); + } + + 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; + } + } + + 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 = JSON.parse(dataText); + if (eventName === "meta") { + 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 = nl2br(assistantText); + scrollChatToBottom(); + } else if (eventName === "error") { + assistantText = payload.message || "模型调用失败。"; + assistantMessage.text.innerHTML = nl2br(assistantText); + } else if (eventName === "done") { + if (payload.assistant_message_id) { + assistantMessage.article.id = "message-" + 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(); + } + } + + syncNodeRailVisibility(); + bindNodeAnchorClicks(); + + if (chatScroll) { + chatScroll.addEventListener("scroll", setActiveNode, { passive: true }); + setActiveNode(); + } + + if (composer) { + composer.addEventListener("submit", streamChat); + } + window.addEventListener("resize", syncSidebarState); syncSidebarState(); })(); diff --git a/templates/home.html b/templates/home.html index 28a73fc..88c8c26 100644 --- a/templates/home.html +++ b/templates/home.html @@ -92,10 +92,11 @@ -
-
+
+ From a0e5e4c301f26b2fa028a2052f645019e6ece563 Mon Sep 17 00:00:00 2001 From: bruce Date: Fri, 5 Jun 2026 00:12:47 +0800 Subject: [PATCH 003/111] =?UTF-8?q?docs(source):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E8=AF=95=E9=A2=98=E5=8E=9F=E5=A7=8B=E6=9D=90=E6=96=99=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../【模拟题二】试剂盒临床注册文件准备与审核Agent.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent/【模拟题二】试剂盒临床注册文件准备与审核Agent.md diff --git a/docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent/【模拟题二】试剂盒临床注册文件准备与审核Agent.md b/docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent/【模拟题二】试剂盒临床注册文件准备与审核Agent.md new file mode 100644 index 0000000..9f997af --- /dev/null +++ b/docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核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 对接、文件版本管理、多语言支持等),并说明为什么优先做它 From 933799a882a8ad81cf8d306968e8d09a2cf8af57 Mon Sep 17 00:00:00 2001 From: bruce Date: Fri, 5 Jun 2026 00:13:20 +0800 Subject: [PATCH 004/111] =?UTF-8?q?chore(config):=20=E7=BA=B3=E5=85=A5?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E7=A1=85=E5=9F=BA=E6=B5=81=E5=8A=A8=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 19 +++++++++++++++++++ .gitignore | 1 - 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..e4c4cf5 --- /dev/null +++ b/.env @@ -0,0 +1,19 @@ +DJANGO_SECRET_KEY=replace-with-a-local-secret-key +DJANGO_DEBUG=true +DJANGO_ALLOWED_HOSTS=* + +# SiliconFlow OpenAI-compatible API +LLM_PROVIDER=openai_compatible +LLM_API_KEY=sk-pgvkjondmmrlyxmrfhotgpuirgbtgzrpjpweorhwruflxmxw +LLM_BASE_URL=https://api.siliconflow.cn/v1 +LLM_MODEL=Qwen/Qwen2.5-7B-Instruct + +# SiliconFlow embedding model for RAG +EMBEDDING_API_KEY=sk-pgvkjondmmrlyxmrfhotgpuirgbtgzrpjpweorhwruflxmxw +EMBEDDING_BASE_URL=https://api.siliconflow.cn/v1 +EMBEDDING_MODEL=BAAI/bge-m3 + +SCENARIO_CONFIG_DIR=configs +GOVERNANCE_CONFIG_PATH=configs/governance.yaml +UPLOAD_ROOT=data/uploads +CHROMA_PATH=data/chroma diff --git a/.gitignore b/.gitignore index 30fdb94..e652b65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -.env .venv/ __pycache__/ *.py[cod] From 38529393cc682949c7efb48048c8f73bfeb12458 Mon Sep 17 00:00:00 2001 From: "zhiye.sun" Date: Fri, 5 Jun 2026 16:48:31 +0800 Subject: [PATCH 005/111] =?UTF-8?q?docs(file-summary):=20=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E8=87=AA=E5=8A=A8=E6=B1=87=E6=80=BB=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E4=B8=8E=E5=BC=80=E5=8F=91=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/功能设计/1.自动汇总.md | 597 ++++++++++++++++++++++ docs/开发计划/1.自动汇总.md | 634 +++++++++++++++++++++++ docs/数据库设计/1.自动汇总.md | 651 ++++++++++++++++++++++++ docs/详细设计/1.自动汇总.md | 930 ++++++++++++++++++++++++++++++++++ docs/需求分析/1.自动汇总.md | 328 ++++++++++++ 5 files changed, 3140 insertions(+) create mode 100644 docs/功能设计/1.自动汇总.md create mode 100644 docs/开发计划/1.自动汇总.md create mode 100644 docs/数据库设计/1.自动汇总.md create mode 100644 docs/详细设计/1.自动汇总.md create mode 100644 docs/需求分析/1.自动汇总.md diff --git a/docs/功能设计/1.自动汇总.md b/docs/功能设计/1.自动汇总.md new file mode 100644 index 0000000..d6bf121 --- /dev/null +++ b/docs/功能设计/1.自动汇总.md @@ -0,0 +1,597 @@ +# 自动汇总文件夹文件目录与页数流程功能设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/需求分析/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、UploadedSourceFile | +| 关键规则 | 文件必须绑定当前 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 UploadedSourceFile + +上传原始文件记录。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| 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 绑定的 UploadedSourceFile | +| 解压安全 | 禁止压缩包内路径跳出批次工作目录 | +| 文件执行安全 | 不执行上传文件中的脚本、宏或外部链接 | +| 下载权限 | 下载接口必须验证当前用户拥有批次所属对话 | +| 存储隔离 | 按 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/开发计划/1.自动汇总.md b/docs/开发计划/1.自动汇总.md new file mode 100644 index 0000000..86e4b96 --- /dev/null +++ b/docs/开发计划/1.自动汇总.md @@ -0,0 +1,634 @@ +# 自动汇总文件夹文件目录与页数流程开发计划 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/需求分析/1.自动汇总.md | +| 功能设计文档 | docs/功能设计/1.自动汇总.md | +| 详细设计文档 | docs/详细设计/1.自动汇总.md | +| 数据库设计文档 | docs/数据库设计/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/数据库设计/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/开发计划/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/数据库设计/1.自动汇总.md b/docs/数据库设计/1.自动汇总.md new file mode 100644 index 0000000..194c506 --- /dev/null +++ b/docs/数据库设计/1.自动汇总.md @@ -0,0 +1,651 @@ +# 自动汇总文件夹文件目录与页数流程数据库设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/需求分析/1.自动汇总.md | +| 功能设计文档 | docs/功能设计/1.自动汇总.md | +| 详细设计文档 | docs/详细设计/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/详细设计/1.自动汇总.md b/docs/详细设计/1.自动汇总.md new file mode 100644 index 0000000..36f468a --- /dev/null +++ b/docs/详细设计/1.自动汇总.md @@ -0,0 +1,930 @@ +# 自动汇总文件夹文件目录与页数流程详细设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 需求分析文档 | docs/需求分析/1.自动汇总.md | +| 功能设计文档 | docs/功能设计/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/需求分析/1.自动汇总.md b/docs/需求分析/1.自动汇总.md new file mode 100644 index 0000000..9726de5 --- /dev/null +++ b/docs/需求分析/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 流程。 From b7a3d512c010ac1f94442cfbaa4a7192d217e386 Mon Sep 17 00:00:00 2001 From: bruce Date: Fri, 5 Jun 2026 23:39:38 +0800 Subject: [PATCH 006/111] =?UTF-8?q?docs(materials):=20=E6=95=B4=E7=90=86?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E7=9B=AE=E5=BD=95=E5=B9=B6=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E6=B3=95=E8=A7=84=E6=9D=90=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../【模拟题二】试剂盒临床注册文件准备与审核Agent.docx | Bin .../【模拟题二】试剂盒临床注册文件准备与审核Agent.md | 2 +- .../中华人民共和国医疗器械变更注册(备案)文件(体外诊断试剂)(格式).doc | Bin 0 -> 33280 bytes .../中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx | Bin 0 -> 21643 bytes .../体外诊断试剂变更备案-变更注册申报资料要求及说明.doc | Bin 0 -> 43008 bytes .../体外诊断试剂安全和性能基本原则清单.doc | Bin 0 -> 89600 bytes .../体外诊断试剂延续注册申报资料要求及说明.doc | Bin 0 -> 30208 bytes .../体外诊断试剂注册申报资料要求及说明.doc | Bin 0 -> 58368 bytes .../医疗器械注册申报资料和批准证明文件格式要求(体外诊断试剂).doc | Bin 0 -> 31232 bytes docs/{需求分析 => 1.需求分析}/1.自动汇总.md | 0 docs/{功能设计 => 2.功能设计}/1.自动汇总.md | 0 docs/{详细设计 => 3.详细设计}/1.自动汇总.md | 0 docs/{数据库设计 => 4.数据库设计}/1.自动汇总.md | 0 docs/{开发计划 => 5.开发计划}/1.自动汇总.md | 0 14 files changed, 1 insertion(+), 1 deletion(-) rename docs/{原始材料 => 0.原始材料}/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx (100%) rename docs/{原始材料 => 0.原始材料}/【模拟题二】试剂盒临床注册文件准备与审核Agent/【模拟题二】试剂盒临床注册文件准备与审核Agent.md (98%) create mode 100644 docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/中华人民共和国医疗器械变更注册(备案)文件(体外诊断试剂)(格式).doc create mode 100644 docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx create mode 100644 docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂变更备案-变更注册申报资料要求及说明.doc create mode 100644 docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂安全和性能基本原则清单.doc create mode 100644 docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂延续注册申报资料要求及说明.doc create mode 100644 docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂注册申报资料要求及说明.doc create mode 100644 docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/医疗器械注册申报资料和批准证明文件格式要求(体外诊断试剂).doc rename docs/{需求分析 => 1.需求分析}/1.自动汇总.md (100%) rename docs/{功能设计 => 2.功能设计}/1.自动汇总.md (100%) rename docs/{详细设计 => 3.详细设计}/1.自动汇总.md (100%) rename docs/{数据库设计 => 4.数据库设计}/1.自动汇总.md (100%) rename docs/{开发计划 => 5.开发计划}/1.自动汇总.md (100%) diff --git a/docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx b/docs/0.原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx similarity index 100% rename from docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx rename to docs/0.原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx diff --git a/docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent/【模拟题二】试剂盒临床注册文件准备与审核Agent.md b/docs/0.原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent/【模拟题二】试剂盒临床注册文件准备与审核Agent.md similarity index 98% rename from docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent/【模拟题二】试剂盒临床注册文件准备与审核Agent.md rename to docs/0.原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent/【模拟题二】试剂盒临床注册文件准备与审核Agent.md index 9f997af..7ef3d5f 100644 --- a/docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent/【模拟题二】试剂盒临床注册文件准备与审核Agent.md +++ b/docs/0.原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent/【模拟题二】试剂盒临床注册文件准备与审核Agent.md @@ -28,7 +28,7 @@ - 自动识别缺失文件并通知责任人 - 参考法规来源网站: - + diff --git a/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/中华人民共和国医疗器械变更注册(备案)文件(体外诊断试剂)(格式).doc b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/中华人民共和国医疗器械变更注册(备案)文件(体外诊断试剂)(格式).doc new file mode 100644 index 0000000000000000000000000000000000000000..af0ff63480ed029fcd8556d95f47d95c49fec84d GIT binary patch literal 33280 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 Vxv7O6yzX2yjBjZQ`|qfM{{f+Kx9b1^ literal 0 HcmV?d00001 diff --git a/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx new file mode 100644 index 0000000000000000000000000000000000000000..337c1f5d150c5f2987dfe01b6a7d1ec34d9e9c8a GIT binary patch literal 21643 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%0AdFbNDs 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%0A*O>_ zMA3Iz0aSfvH~a>+O|2xfC^;qt7TxUBPKA_ZPUgy!8RM;(BfnRvrf_RD6(&}EL0Bk`K1ij1S_JPJvWLwbf6AqWtSMF5r z)+o|-u)Uk~ykwqep=>{I={}TWZH`Z%^|AXqbClQ0>Fb8)GBsX%M?~XF{%E3!q9uB( zXKIt|+MQlVB*y#3Qd$avNi?Y#igonH#$8QF=x3(sTYXP3I zp*Z*+p1n!&$|zyLyaF~P(h@Ei2gjHsY?*|v%pR=1a)8(#(G&V7DS&)#{`7RznI6O9 z@yfe>v7>uj>RCy*O`R=TT^Vl-^K#9e>KRiMIR)`ioS_|A@3_N>pUZOEp4OsPIAXII zgH2_pj<49CARU2HT5-}8pM0#BV~PDz9<8`AMmUvF_u1LJ6o)QybS#=?p^k^LDWeZ? z1iOV)Aw4@qn>^FI| z!TX`|7M)X=?YF~xq&mdLn3dZuIX6{}3srE#)X7^#X1cTA)v^tr>FvVplfE1JBGqq- z9OS_CB)N*aM?ORn$b^zn&t&s61-Pkd-zr80_S7$WWlS}OX6r=_a9=Hj9a z*HloabkW`qR9s8BCxX`++{fUQxf_QK<5==T>rh>?%^gwqD`KbW7n^CA@`}R8h4^Tq zo@(z2Rsj9I=N%kcjv(S&FPAn*ALMXL@YJZp*3i1a2q-x~cgKkS;!Rv^?Zd9g{0?8w z$k`pU`D;*KjDUo`7E9`1%i7KHi-sRjI%VF=epj#5kZ)3Ok#|on%aFra`YYU!5ug_H zO0|&icAy*e>)b*9ZTk1qFH#6?EmjMx&O8$oXaE*80tskfaCQ2VAjko%vHz6?1y%xw z`|!1n0Vb%QqJF?Q`2BG{jso^E-*ADq$-y+(l+UNtR0QZB_H8f^_IvYrNi`lX=>7)d zU@I{n|Fo5dgN?;t39ws?FJYz0lMwWdfpKt`laF`l@^Dbk6Rfbhv&ZKl5EVTLR6&vl6&GZx*t(5CtBtuQQY!1c)z$gxG%l_7C3IyyXA@ literal 0 HcmV?d00001 diff --git a/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂变更备案-变更注册申报资料要求及说明.doc b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂变更备案-变更注册申报资料要求及说明.doc new file mode 100644 index 0000000000000000000000000000000000000000..ad84e26f9075b2042e514ac815ba681ff48e9cf4 GIT binary patch literal 43008 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 literal 0 HcmV?d00001 diff --git a/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂安全和性能基本原则清单.doc b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂安全和性能基本原则清单.doc new file mode 100644 index 0000000000000000000000000000000000000000..8c50bb5347a9b773531d8e82ddffc921f9b1098f GIT binary patch literal 89600 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 nkH literal 0 HcmV?d00001 diff --git a/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂延续注册申报资料要求及说明.doc b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂延续注册申报资料要求及说明.doc new file mode 100644 index 0000000000000000000000000000000000000000..e5962b13f48f6c81a551d144e6333414792349e9 GIT binary patch literal 30208 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 literal 0 HcmV?d00001 diff --git a/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂注册申报资料要求及说明.doc b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/体外诊断试剂注册申报资料要求及说明.doc new file mode 100644 index 0000000000000000000000000000000000000000..769a99a15788a3847a33899448abdbf69603e868 GIT binary patch literal 58368 zcmeHw2Vhji+V*UEAPLoo0VNb6QkNDwgd$QmWfwwBNZF7Agn$WIN-zXiA(+JkrOO3D z5UvHectt=(kR~7kN)ZtO0YybAA_xfkKhK#=b`ywV`R?a;{~UO7&Y3gyop;`OXXc&R z{rjq%T>7toQ=-(>MYxLFc5mU~NcCP>h*5Z^+iknuPUkcLkLB0@Aqt#du|*6! z>Fp-O#fbM4*u;c^A}&U;o3`K<2Si{72@)m3oZSqXeSTIA39-VW|n9uT4l z?wxVBIr*!PtiJCe#4fn=D(ilt===4W(qHNO+n@%b5$_gJk8e^t3vx+Om1yef3_}y>vS2UC~*+mvvY1 zs(LJa|E=$({r^?(s=ms~cUkT#zrXihR(zDreSC!Ict6@1xZYh<#b0kXH}oLo*WaB2 zJ?CCEv=+X?80sIjJO8z$L;2k!&ZXJHTV<^<_nf&PY4VKjX}gVA^8J#1g>n;yl{ucN z^XGdOyV#0dJVgu9OazJpmZjOJLPy2R_ZH&hgeg;gundgz)xNhx$ZPqjdGf85XrA(Y zzE1zhqy^!7^Y4#P?ybd2!xod{y^Y8WH|0)Gx@wT&g0=JqMEDt)T89*Ciq&Guy*Y7v z{F~u6(~`7+!ZrB= z`D~@-V_%|rZ!N6zYo`QdRWFp^+i3YM@&u!J8rhbHc*thVc%UwxZSC zhVk_yUQ6l}=5K!A=$hNXcx8^`dpoUsvJ{?35vih7+w!j&q#IqFOUCz>rTln`HgFBp z%1b&W8*C=e)ST?Lsa+??Z_Y8$ucVFi>#K!sB~%Fc-nvA-f)OTY=Dt~^q%rj7MDmZP zplb__2Yj{XmQEbXZ>>b_td)k~h`>T}vGmirL_8HI6vRsRHiB{Y&#gDp;T9}<&MZu) zyTh%mR?5ZEYcd^f?MlRHwe%agZam2+)U(Qt0il`Zk&srG1(B4YRTS-DNl?ToSH{|8!>o6xb)!-A9uFyDN!zkL0LX_ zrrka@J<(a>SS^dAADzoK>|?(kZ3%B`xDmc4(`pg(CeQFoP9N9U)W=}8FiwB7lo-bf z`sXc-?qiTC9vkFscgT&ihSa=Hsphy!arZ~u2tQ$_4C7a;#of3)Z`+JcsXBc}!{iy; zaw?lEn>`|Y>}L(Tjr&G4GN|uXi)ZSNd@uWysD?AI8Fl*9L2FW*4-^Kv_R03P+f0-U zM+Ny_g}Ty6*`+*#lp7vr&7ezs(WJ4)25I8~M6?AV9nnT~Uc z_nKPiI7+A18mS$O8f>QQBz4#htrnHz=R=Pe2b<~Mb7rSha=4e>nSPR5psSv7c$s`m zHWS-SsBv|^te-ZB|5(9kqsj*|`)C`Jj zYPdCs{#7o$Q!>N1%~%+HH#$3qmMQv}DkUhsw_4aU`J3|+duXk;rv1|b#Ve9?{0w)- zkQsh+qL-a*_fT4XT)mk$!Fg5->y31g6ze&cb;;7}^rQoQ>GVr;d!%fEzdqUYNxHDl zn>yG(>eu*`6lW-g39vhSvlRNs5OYxEtwHOez3lqP4n}W#TKLb#Y|EPbj)tY#=L*lw z@i!}qE0-JLW5cbMTe-EP7^@?`!O`F6R5y$b_l)}4GCNjgwj+Ff{@Hx;3iSdpr`bS9 z-hJ$iQpjzJ;=dk0!&@!R?ev22tijj77A$kiHhO1l?zwl*Z=)2C7@4z}&<+Nbd-hgr zX_5yhZz-$DKa!uwX)2#(#r&TvGbM$i6?!Hvj6QAf7_$-5f^9z;d@kQ7nQuUj?l#^j zCma&t-lQtqptGDKrAU$AD^Y`ybD5%|{clOsvQjD?zO*!Liulk6JhWjI!PiiUDti0_G z4TE}*8XtWz$Ty0#VV;|sPrg^8R(%DSSg}ldVI&o z7UMXSRBD-wM{xytfump*FGEz&JIgUr%Fn>I!9HMd#G$l`dYg$9Ka{p@hSfryo-|^f z%Z|k(B75St84pBM)a&#mZv91-4UxcvMnkz^R_0le;zdnXxQO8egC`w z>e?6$SYkc#L+LD~>CjbEU#+T7HoqjvSANZpjZZlRL?`R!i-%i!t8yLcjS` zIO*LYOVW?>$4Ao>Ejkg|06U#QPodLpRCX9iH#wFC?NyvA?DZ^M{P){sQhr;nLW{ek{sQ!R!YQG zH0PlxuH--cuudJh`!IJ@GFv}$?{DVlWJk`FDU=fdn*5aVh4Uozq0>(@)*0cD89sJD z19ks>IpfVcwDv3gQ`_y6&D6c@997blX-nSb8|y)htN4X(A?BU4*c)*sWC+p){Sod&+Xdz{Izv9!m4J``DSQWKHJ#8PzcclEzxdi%Q zJRjt27|pM>3#Vtwuctz<8Qr2h#>ldbPD)3-hmGL!88`DcBvp#@v2zw;Km`3Hjaa@= z(Vlv-qAn@L`Jhj77Gc!bG5(Vrn~C#3O$5uK=9sqS>qB+=yUiuRRhx6-m|M*qsdT8( zYV~XIF{!b5Kf{$dkikP-;v^qX{;Rw+HB1U+P1nvEmce@UHxJ0;v!hRxZGdjc(X4ZR z*@*VLDqI}DUe6<3}+4OBWl{o=}ey-xH^t` zV@g&FW0oU0`s2uy{I$Q}IkXp+b0=noni{T0^VvPuW_mqy>fq+doDrl3FD0eyF`Y+v zq@zYt!5lM2y2!p-Qit#6xOEve6LUZ+Qw~%gcfdH!SP?UYmFH3pNv)URtrmV`i?^A6 zuvjfF`YBP~DV!@rK1|sY=?66taoWHq$0%NXll_|du5x0DWvRUjzkz1R5en0gaV{~Q z9K>8v=KFy&vL}{~;oyj=#Zo{Uo$LLhE)VfexqFJ#4^%HdTkBse8eODhq!i%jUZ-ad zDOjtl-rIsIUlCp128v8eTd}>%6i7YR%5lu z9Aj=j;#MwO?^6Z;jNqz;PCw?^#zjxj*=I3}b^tS}Y;$Z23&JrshaU4=L+T(0{k`np zPF@w!T=X@{dq4BpA)Gg2O4$?hBfadL4R0+NU%c~s+tem0^81vikUaNvojxS5 z*GQedYs^~wtund7WJ(Ch2{%W+5Yas%2zuT;^YQTR5syGFF#OJ0JLc{-9uFNHy*)2| zlA;dl*-;MxPR~}$0b}i~>6toxwdC3f8x6}R$vNU-8CJ_z`8#s<820AuHu@PHp{=N! zn-hKPQU=i7-@G{5ws3hg=|%pNyhSNX+Az)l`wy7wd@<~5ABnY>eaCpEa9(gq)FbIH z#iGWwzhg%pG)tY$%g#Akl?GdpT(eOcjT}`i$EZWi6;qRDOHi6L!?47@qLKN>$<$rs zxa=B6Ka?giTu`K&iMpu2nKNQm3qw(-xterk4#q&34`LiH`m<9UA$%dz9AOyZYLa&f zdpwqBQ~pxS<8ie^r>AUr-#9u+ayj!{xlkA=-4$hh4Wp*y8?ItS<-8Fis^Sql{hmdM zQX%Op#ZjMij6<2CpNZUNwW#nqJ;O^LWZvZrxiIV<&-_=^MrRuhJ7*n68L2H$hj=4= zLsIWl*{5_w54SQgID&2YBdlpzEiWg&i{7n@NzGoUl-O!nF3tjQwMy|7xtMc^R*S3c zYzot0%6+oQZK}jRCN&FlJ(}Yz6{pNSeNuW*JMpnso{RO&BI?NRvc&9NEU{d5kX)t1 zXhm`rc}?}&tg(`zMuEC?Pcd2P3cuv+0zW2tLW&Dnxd4y zIq?K$A?Taqfpdji{b5hnE7aEzHuo7r2SHg)ZVSrdy0}j7=tbmMK&O|QJ$NW%&xBD0 z?CH1`%h8XIoujj$NT!6D2jjzuR!a;@=VRXYYbN0!_5|Q%u@{qTt z&Woe8uv*H>tCUmEhf?-z$>(gAO_Lp?rXH|_#LSQ0kQ6@Y=!CRzSEL~QFkdeEK&%z| z3UBn)j&*3#uJpPfY4Jx_1yx(;*piad_l_0q=E>Ki>C0-NOrSsNw4~X#{H0iBMfztk5=w|yhV&%ZpRe#FA)CyZ|(7D{e6QzHZ8wp!3{6ub_- zot({4HB%<12Wa)~JR_p?5Sz)*AY}nH=J6Sm^8(-gk@Xd@lY zB(X)1Z}^pRRml_e9-#T`U{EoHm{ZLC^VC;4>!oHjsYl;53=9>9M(CLXq8b^7MMP=o zl3FVgj~E-qZ_8h(&46+a#ZS}Yj~I7jjqujxcQNlrZE`ejSVT*8!pFlWJ;^A8RS*&HHCo*m|!xI!)+J1rYv%PypozqBv@&A0Zo7iU=T1EpoyXf&=P0|Oat4^lO z;0dD;HT_d()Ho};H`4X!8z{p1dWZzvN2k)d>Y-BKJhgA5f^_op9bx5n`LVVP8>0YgbyL59-vkBsLLz+#B zXraF^_2OXr@9W#Xj__Cq_APaJTmTD~ys9AxjLjL_z22)|h3A07pr)XOy1l03v6kcU zLC0h3!yZEC?ka@#P{B1#BwWSY(Rs#WN)aHTzE08r=4SG9s!k{1D&O%`L?cm= zb$SK33b=WrodSV?9tZ&j1EYZ-Svqy)st4XneEDl%oRj$`u zkPF;nJ&*ZoYT!Q;%iZevg>F56z4sUaIsM;V|8A`BDZpZ2HLw}j51a(g6Yq0DW zXJ3Dx=ezMmvG#E0zrOxGSkGSqKLc*i>#GAnKnP$0Qh|xUOThcUm%!7`)tB@4dU4Lp zZ#|b)_gT~lhG5In?`Zp;vtM%c`ah>V3LnnB7Za1r2` zy)IA#WA+`u#;S7cKBgLWe*zrCcW@fVe~z)d3$}FD2i^fHU`!tdOaRmv{~Vq#Iaf}J zm($K)%6=&k=8wFT9d(H>Wk-FF-pY>Pul-U?wm-<_B7pVM4BKF>z&79<5P}`L1|Sxg z4=e{h0s;br2nPNQYy&<9tk{*R#}3>1z&pUV527yt>OKT_U_J0Du&g%p%fMgbfxnhA z|DVQI%n(iw8gJZP%88nsf4f_Kt2$<#O$3J4!8`zP1zX8|>IpIXVekZYs<`t%jI| z1G)eqz!jiE5cma%0EPpPHv+E!I~t?E0d51L7xc`>pi=;~dgB{#5cm}M47k`A<51x7 zeux7Y2V6RR=#u=m{nDW~@c1JB6}v6H^kTuKLzCz_iX2chzgIN3*AH=q^>v=#D?N88 z=k|(c$3)#yGijd1;T?Wl2YTsB$5VO^vR;XJR31k5_O2}F^fK}ETXT`l^M0l0Kudpf zo`)>`&AI%dv8vyvQO8fCes2RYsN)=<5Lg4KI&X=34*@;}&I7x#Q+*uxfV>b0K2ZEn zi2A>A7asTlHBx+)csW_@jEC}YcW3mE!tj`xzW@!nO^mxK6z?$k!kYS0+2J z?9<9#uFE<5=L(^+YTTD&Tet<*Wn9^77%x|Zr6m#95Y_ru$sO&^UszJ^y`fDBSIVpw z?%DI7B-c^7pDcT#^QxtD*ro=#FYm0O=iGsDw4R}@6RlvSEiqRdmE|zwRx7iN!?7M0 z6iG{Ty2(&BlL|v?*bByZSpVIK3>jAetBbUX2#RFQC|eolwLnK1Xz^6BXs;w=<{B|m zbkW~gB4sz=jWu7|8!`3q<~k!7j@AZpr??AjUnDm;+tbVSPFiU=T;)2xBgG3wS~k!M z*wgM@hthJ8-)M`>`l4+EtvjUsv5#g0XSFEXJ|DZX*xom*Qy6WvxrqqOsFs$V>kK;33 z;^^noh210e&HCr;bs4>34dP>`y*78)?>1r;9n|2nkDb=w-0|&WzhDf?YKR?4nbRcu5_z_eWv~M zNF`-$;RwwYY#CbFW6Kz>N4phUXfrg+=x=5#)%d@7PfAc^X@1|Av$KF!zBW_(xaCDV zRA~vq^3IFXN6`9;77mu!>IT(j$p5T0M;l?9l6MscF#k+jT7Qh6Pdd@!ft=5pC+%pT zrqh$oHWRJieeAS}<=VTnq>|PH>4}eGw+d|sHey|#`>}??!i3?K^#ElP)8Z<-gQFaK zaTwk;H+&j*wX@ww%We9k6{t>6`y!p5mfg}Kv`;qeRf6ZfIl^jTtL83vQk^zr42At$ zn$0}z_GDdMGtl09Ijp25N4V%&R+f{ylB^cmnKCAw{^lL_s5Z@}oL_0BN*gtAyR=88 zZKiDhs#Q43bUnJ{m)%AgGxNhQ4dYp#v;|+3!uq8h@@eeECmmI6rww*kW*jzLFdjrn zm8;?(#lPfkQdnB%sTyjYq|^4h%#L-~G13<;Ih=Vw?Fmq^)4GWE^%`HIe81kx9p$Bz zcWy&giwvox0^@UTC+E%byKI}R8_E#XSC#ZB<%+{+5Oz{a$?Is3lsPt&?DteGwA_(W zi1LqT36zvcj@+{~$Z!5;DX&WN%LOCl2d!q3qYsX_jy)xQ*jsRLgtU62-0@wbuL|PrkObJfi_yRW7%%@gQs+QX*0WbXiTVku1+s4xtTlmTQ(D|gZpIDMpMS2 zS`O`Hy-eKIEZvzWwtLlXm@`^6NvSREeP{-FtuRl5u3jjyAJ4Y^LRgT}JjGcZOGf zC^aY-N$Ym8Td>>jsc`OX;dkyhP|r3KcTub`8w;%#6}C^d?E6Uzxd(`KK&q;AonvQi zX|Yb7F?(XCQ0}^+WbiRDjRNeeuv*%sj+^yZ#+xH(>3(0_*qJ|L7l}W1yLLix@d&GLTzF)@8`d7T> z(3Bakl1Ns|dpQl`sV(UAcZ=uF6ltm3OeoF5S3AFky97HJnq=F+-?9|S-tB7A$t3O= zS#GFlS9>Jcr}${Q)|N(Z%cmZpELvInDrVIqDQ!#bovQ?EcJ_WY6MH|ktAZM{+#dl; zV4Q-()+L`9pX|XXS4nAF!+WPNB;_Yvbb4}N{Rp{xP4;0ev|d^2O$@=l$s4<2X>FN~YjDqlfr|2Vlyj5joMRnI|@)FD=d$=fry%$53wKu|M>Jy&wgg5M!W%|q=dw*&xGIugR zDj!Odshx6Vj{IIy8)a>?4^wCA;Ow3_U5xbUqS;ViO=9_cqt>NMSDn5?%9921dAITH z@R`%=j2NAyO2E35x{2ylISOM8e4jOcdZxeD-^%bRlq@$}Bg26)I_z8J*RuAYkj^&f*=tgdhb}kw!Tux{J@+lB z804u$>@%r-{yVuPMuGynKNu@>&6@GDbJzFXc2lzCj()%x$DKip`EGu>YuIXGyYV;w z1G|UiNRxV!Rof5ir`g0SUN1Wa2eo~Rv?-+%VQgPCy0}|ttA#mGx{Q~7M3LP-U1ZyE zjH*={f4!V?q~WMha<8*H^+oIhfflRNtDV0N-#&JZN7%FRtR}g4SoSM)m;2O%VU2Fn z#{a6kYNru-nEMNPDYd&KHH?zGF~}8;wn^#7mc?DYE72l2Pr&^EYzNeTIS0h=rFZMF=a+jM z6h#=~7!Qd&GDs-#vG?k?bIRd(Krc zG3MvA*{1V29biwCe3yGd)idi@?!L8|IC48}(2Y}ClZ&2bMXA_G&DpV>VlWurewo4Y?EDSSZqGuk|pih(DC zSS`*p@X>a^s!)_Qeui3s^!H%kb6wbn#BYxM(ww=b zyKDoqp>eXFSx?TVUb2TTy%X!tNe@RVQf|ok-{sm)9+|q2UCoZk8g{M;>BApqvw9}} z63tUaozI3r`=4y<1!7Y-SBJ=$9bO0r%0(3 z!<~);jXlEE*V4IGeCbS4&L(nx|BvKZ?y7O{hjXtjM+V$a$NnQJ?@{f%M9Cv^myxd+ z2|1|RhB|pv)vlD?YLsY<;~8ZbqZX}8@YEf)#T&7ECdeIv&hhxz3+7%;;@&K0SDyGO zDacbf|C;kXSr_bi9p|8O)Ixq@>tgAtZ_2r4uDo#l$7bpnZwz(l3+$O#BT9yIWW=vB zH2Y8$gSPiC#{;tf96itvPTGsKnM}E#=g6IlrT4}@A4;ElXHA~*eCX`hUIV#finBo> znA@fd@Q-rW)?;*f_HR7j(;xDZCtFC3g7T2(tLXIe%*Ige+8;Uk!>Ou|;+`sJ3R8;u z3ied27n|uy&T_N!cdG>>{g{r^_;Zp4!QiI2fmlX;5}mXkhf!T=Yl@o7-IQd@q|xtbIqNV z^u_sg<^p>ri&N*Qw&XdsOud6)arC=Mca~r7(tKlP?I?zkCsS@TJdd68)S$VdwJD$B zs5Nqgg8-hWJz+$q}Bkcl3Ox!^aeg}N!V?<2;Q@Wt4e zQpucC!tI>ZwCD_A(uwI%o?afp5rEag+L9VPV`h8d{(Y;3q3D8B%Tks`l#G=lRJH`0 ziQiTh@rL9=#?KsaWGL7Eq_2)6S2@MvV?SE(yn&@B$NZcxPb!l9@9?kF<0OlbHd8{{ zCl~=T9i5(KlU_p=K;%(88#E=op_eTojG)F4V=qmtki_$sN4~H zQ@$t8X<>ORvAV?Xb4a-_lNLr(4wj^e94$J>Th_VBsus9wmMO{kF{vlX^OesU(#IVS zCH3Vwv1|#H)m97B=PI2g_IRi+(K$7BCV+FUxTl(Wlb3x6_?LYKLog;%UdlROLu|}5 z25P)g)9w{|J$mQ}c}CpSf({1CG1-GCY3F6{hV>ZdwAH!JzJlirFfQk>Ov^DxXv7M2 zWvyK}`(dtaCOL{yy{kIk0;Aaydrg_zRh&Z2wPe}5uuT=M++v?Qcaf&%DakI~d0G-R zJG*ekysszm43-hbR~y)r)&=QH?r=fW-rf8 zRx84s4`mwiq)5sVx+o1@rV_W!S?AA(o-ng^NfCLn)X7BDx>b)(p7+IXMe(oO z9nW5xi?662SUoUMR6`316WzpP;!&-&#K71nPz=CZgyz;ugyLxce3;-~9dnR+aUY)$ zn(^~r`SsUQfHsvc0jq(Hz$V}zPy_Z_&46Ga28ac&4#1v318jst@eE{yW4{=12Dl7V zj=)x2pqCN*MuD{hh4>KI4}1(v8zjVBU_G!AaEXMSC{Pzj2W-Imz?Z-oVDVt&4LA?n z1_nh5kqo>5tOp*C5#quL{yT8N>ED(EZyb2zMc}}e1J7yyw&3Y0x*qry;+3|Obhe=^ zw$1$I?)$_`+kcj}BP|`b^B(fjVN2VNmJaJ|zge8GU*Er8rv0mEPP;tT^CsX1@DS>_ zFE9q!28Fk!C;a2xPL`(=A(JMNFRtJ?BvJl_T^MQ!&} z+=I|o7Xl?re9L|*QKEbFRwB$jzI+U76<=G5pYTxP4wC893Qz@Mu@UuFM! zrg(o-v2|~8%=~s&`(J<7(?*~y8T&rutmSd+3kTi-YCz`302#n-z-I{d`2e$l<-ixf zMIbd6_T#_{K&?2~6az6p22kGg|8q0|mlbn}V+UL@hty&ps_OGqJpT;*0aOKrJ)A1BJkH;NLi^;|m}x3HyYA#lU)? zXDZ|p&>QFv7=Q@C1Plfq2fjb?@#c>=pIE#6g#5R*=svsXuH2_j%GUm&NAgeISSv_~ zO0L5NF8PLi95tNJPwCOOR?&8kTE#m_$~-BibT94P!FC zYWoPbpp=f;SvM$i&q>i)G1zm0DqwrOTfKEhovFH8a}V|RIqL8t&^HnF0=xjM2fCn6 z9|u^!|AX!RG^*?2Bi%%`kv%a}dC`83V)uW?(4rHzVFXX6Jk{k@0ZG`Q^+1(96j()4>N_Rrw># zo;(JL5J{dB)-Uw;e0Yrz_pdR!)>sqn?o+tmnz3Y1gee}n=q9<@YOp%a<9N&=Ha{5b$z zZrYvC?L4sdk9%35@4w=b>B^Y&NR!~jbpMl=b3X26s>|8`z2=i)20%SJ(Qk zE$ZV(LtI557PRgY{d&7-aV6llYVM3LUAUk>aS`dovFSbBglFpf`GK(Lh2;mnRUKL# zKS)w-d8)4Z@-Y4!iEDk)qP{Mqfv!1!ZtBW{C{kC~&MD{aVtP_yDo<6RC0;d{1UBs%s2i}O0#d~iV6K)pEWj5aWK~_W z5+TQ%&!zQBou1SLYx*vt3R3FPw<=PqDmsb45S^&2>xA4_)1uIOxQMLz0h3`c1D!$E z8m!8EcX$bx_LVaNbuNz3EyeBTm+dWdEfE?k)VTB>o?2+|kVq8?A_HzdT59Acrf3kg zk;Cpf)C%mrWf-RDJywW@um{Hv*xa0r$4UBt z%&{Ik?X4Qh=+suN7HGu|#qVy&A7-kc6~B!a_*%Bg!AM6ZrsxJcrQ@mbXOM^#Ny036 zRObVthNJC7*Vk2BK2*fiN0S;VKtHr2)rM-I4M`S4$TyI`XLV+$nB7o<8{upp0v!B6 zQR}R}`~yeqBkSubS@*ld0+a#%PL0xE<4rbM)hbO|?w9FICDPz8cXH1ab-DmY%xLrV zNuRRmLe@t^@1@1M7I#PM@epjUlvI*~D~g&$G>0?+$B>sv=5Tc{wrSr* zYeT#q{$|R~XLc@|xcP$x_5F8!x_0Hjo7eh(Z0f!$&_Auo(N@Q; zpDE0!UAX+?8|_^7w6QeEy_M^?H!J+}8sE*B)$a7J9!Ji)u3Piq;Lbmsy*|JGmxce{ z*frSjYTq7SQ{UM9?7{P^uJ_vI-@V!H4Gn*4w!HC-rF)~^H(h^V&AHg5HV4|Q`Xcah zMO)RaYkX$J?Mn%Kpy^ldbzD5-{ISDPY403=X6-W*gJVB5Y_V65|EcS_YM*>KDS8qp zVz=YB!%EO=#o?7-Zw8%$(bCvA#APIvj=du;u(N7p^6 zr!1Y{n>1zDQ_ELA>=iw3^t2bVkG&c9PS}ALJMC>zb4BlU`Z-lA$6A}~Pg*(aiMS)R=P!QXWN`mae)5kv(l&l|!Kz*MH@50OTpYS(((6smKI)d< zc6R?^XUt1iH9u{9w{TU`%LgCY|K|GZhi{=McNTQS___X5QNX7G*k_0_sYxS~nmhd+ zkdU5`l9WLX@%eB-g{^^=Lry)pbZp_#wux^CJ!)RNXZ71xMUO=R-FCk4@seXl&Yqsy z_M;Qk4?W+{H>$}ZPv57L_BP9V>*B;FD{HR$taq*LP0~*7yzi@T#?{#M@0rz3^!Xq& z{Ki?v-Z}rV$a9}ud*sVUziZr}f=j!*XTI5Mg?hfs3G~sdR2e2`IW9-?{>WX>@WY?Rx|DBN9&Fb=zf0rnI{fE6#d+T z!)(d#)U4+6@c1Ux`+oB5bdSDqH^M&isG7R;o7a!Fet1BgNlVA}TGuJD;O$+{z8|r6 z+M@yg$o~4ncO$=kX8Dizr$2VE{WkB0P14tI&vZYsvP-1_;uYi^zL{QAILuP3Yzt|_?gKYabt%Fn$vadD;i8kaKDrX;QpHutf*EoyN3 z7k#(KPPSckJz{CtuE+AWt*-W>*XOYh%=|!f+&Agg_h-jeI{y7r*ZiJ(#JuI~=G+FI zzo}WXL4}T;g8I5Gxs==BqdLoc?{5&W_Qfunn#N48+w5fbtxZ>TZTsGnT?*EH)ytH& z=Fs_#-;PRe+qT9-vkxxXHLmOP2Y;OS`o|ys(pcR0-Fm~D#&1k5CsoMxa2-5s_oLUo zdF$KP)^*)E{Dmv~zkGYh$a(o5u|LMep8ovG*?pTEhkbh@dh6J?bv+N@`RQv%E-sF2 z>b`h#+`BHa&BY4UBdXSZb!o+%6}6fSdcIpP+Yf_JWu5qLP`}Kt`&Bpf z7&Ii~=_>i#UwS-$f77{Rbb*trdsppf4&G9)b#6<)$`9Z4e!{Y=o%Q|aUzq+-@n(wH_y2D|M|1RGp8?h>)v45sT`l!A-H*b9X-lSok z@AsWoXWXTqPIq0>CFjz`ALn)Lv}W`3-k%*g^<>qAA7XYq_E8)EE$^GW`hPX9>-diO zIeB^M2W&fQZSVb&`O=9hODAvY)T4UT=%#7CF7|S%n&=w4=!=$K*WMr6C~y1e75bLB z*|r1z{ay`UU;pcm-~W8cj>Ma1cA0h#>2Yp<@Yq~)z4!j{joZKHd>Z3XCpa~BMUa20 zEwM?h@iQNcPm4M}pu27T53{#6?)xodNz0xnR`PF}4I1n|oZnbWHX?y)b! zcIIrW^g_>BnRC2rPAph3Hm}q4PA`m0+&HRo+VY!2+pPE7{j%|K|Galv4rsFBoxBC% z@4frLgLPhA?qNN#|Hr&-4WGXG^s6`Ruf008aMRCU%}N@WGI0O%-v9a{ykp~*o4ns@ z^o6e9v@_>NjIBA~^eZ*e=l$eyHFo2PesA4;Z{80BuKoD-{pVNonmTdMYvzpuBWqqu zJ-zA4oH7UAcJKLV z#RG0vuRR*s;ej7FeAi-Z1KZYhalX+{_UrVIUfF5$yR7!lzkHzeeU+ZsmhsNXl+zbi z)rlChV&g=&J)bvTm-y4FcLD=Po|tA0UeIS*TAlDAKDz_|>6z)Z@G0x#M>{5jzqMvn zpKJN1*Zw7jM-7Vq?#$+CubjH^(EQvgoxhMsnbXeJAgp6d`uHjj zZr$_7pk?6E^^s(FL&$dW~kM7*|f&JuZ<)Q_PJQ_7 zD^p)x)Fz{FOsievTEFkQbmfqmvt}7WyPYz=wkCGqS1UdH^$UJ#%(eGV&3Yqr<*qhv z4}TSz-!iwy`=`DSZ87t!!p+}(*#CpMKfU$w{IAx&@?NN^&Fp9Db?`p$&Wq>Fi{GC! zI|WD2_`80ewkg?bmH)Hj>wR8(kH;&Xht9neb)73TcD~x$W;)%zFH^qRIBI|8*2DMg z@)`c@rHwxQ_AV}1_-^9AHdkBqZlCA&o8DVH`t7Q3z53YTl~cDEvvSr%zxH^IfTmaH zH2V14twZxWRv`Bq9z^lVIbDmhZW#szv&xAzy8)h8sICM??lxIe@{r?!lmbR6SvoY*IYUKka%Ej z*h2dox7r2FNNHZ%f5`3dZkb1)at~UzvrwpTcJ}zhs3mu0STi65kY!p zhlI{`Q^$-M-JyB&%tUCJ37O4C;g}$NOCFUmGGPo}Gg6ul%}5xFh)1S1Z>iS@Hy@cW zEWK`^Ii*GCx@lut)D0Xn4o^cz;K`iQl5Q>OhNqz;@RX34h%pU(6g?;xe3vf8ep)G) zR?1In<zb1WLqP-N58gX}zU`daB#J3#nHRh{PX|v*?1o zvK=!B+QsM6Px77N8pucdRla0gSS*;xPb#!4o>_4OYFiu9b6D?;N_bb$CMu8v{wNMN zMssr%4diLyPBl6ZXalqdIsqI*t_S!ds{A3%T;L?YA64TSp!{*Av_Sa-INWH<&6eD7 z$4xifctPW9#tm;6%5y-EjZy%9Q%M_hJc#>4Ky9E7P!|XW+5+u>4nRksGr+PkEW?mK z)#}W)&&fr}E{5O@G`JXuzpltFeG+Ix8ufW6=oTU#y2Lp^Nmskb&CTUcK3B%-kjq8R zl?4i~i-KSPtM>OPEf-GgK4mxot5CL&jzUZdi`KOBZEZu#J z-0?=DJWH=dM4<;ALXjwXJl``>5JnNDJHk~hA{OJ~3YZZ@VV2)R0+q!cEg!OYZm#6( zuCAB^!`+ch7Mk3w+7lkNpL7fXXO7nV(uL8JuSu3{fF#}kYyoN^!e&5MAOuJSCIWTq zVUiVi7-$Ft0gZshKvSR@&;n=)7=eMnV8C5?-T_PtYi^#Xy3(M3SGd0%{MbdpUa9my zY$S|rZzf8Av4icARb28f9$d6n{BcD?t7Hvut77eP`@S78PDay+#d|#7M+IsAnrXq@ z9D!9>QXhd|aIa#GxgCppJno$Benj)@;!>PksSrIqWr&`hGK33eV@qYQRETq4GQ>GA z8N#(FMA^BrRgfWU6=Vpv-^`WXM~2Y*$Pn(onX7}oGQ>e&8N%Z?b2YA#3^A^f4B`2k zxvEt~hNx9VhVc58T)ArXAuF@I5;bQ0`&J^GAX}@0)3NOcE!+8>J-576gN05|{-1$PDegf|k#NsYwTCe20V_ASLqeJfGZ zwT#8I-39l4)#&)B zxYE0?FvRo~maGBdlZK&U(fwiKqsk_+YM@CBXgNqc+-s0{FnN&Z(5B3`t3I6>?V8Y-T4O%bnjNfF*I!$fT7k>Y;8bTKb0L-Y#E z6rQ6jVqr+O*pe|(tnHE`%xRNEtH!xvOV3e66C-Agj0( zX%%fcO%nmVY~rI>o7gtSCLS?AE5`SlEhY?|EuQQ#Py8@!zWA}`0`W+Vh2q~$7mB2b z{}3-GE)k!0ctI>OEEA_q%f$D|%f#2=FA8(Lm&D%oFNqz!UJ}1Vza%OKtrE+ctP;QI zUl!jczARcbeN|*s{+F>-2hl=<2sVZU6e)>s&7EJ{8|}@6xq5KmVv8`a+lP2QIu@Hl})BvOYTxdx%RdKI5`ikqYo=**eh0^S2eL#?hmv7>L!_ z5x^)h7Juo=hNCX&i@w=U7O8$t&r~KJd#Dgmxwv?^y1Ti%xVqV!iIdUe#Ad1=ytIUH zH;G|lBvz<0MIhGml8{~?&U6|Dw*;gN^%?QF2zP7-(s_EgxYOi_7vy;HmCGli7>_V! zgiOO*t7cm26+PTsc$JxYH>@fSLrM(Ov2&qrnyZV>!yC(jxZZOSxi=>6Ye$8LmrfHV zLL6AkHDRNc@7~DqFnnwKD8fSha4#wzsR&JsKa5*+Q4m2{N=h72DaJE3KRP#?u{33x zSe^Z?H0_4`Xd$|_hVKgW?W*NarBajxZZegz!;GPEI3cS$*3Vl(S!;plplDvR2@vS51K?1d}W;4ZbQF3`cx zpkJtt(F1AkTDdFkC0*<Bxd>HfWMA<$v#;0yo|BJco%z!X=l36z8i7h6===xs<|y zHr{V4s}k2QTqrB!@(SaJM=-G_~?8v%Ey(hHo6z!)+Ws zkfi)x2r#z!07xd*04zMm^(@kUfC8T7VSjTGpg{cvV998_gCV~N!kuMrjXU!cf;(nU zM1S0A|4F;EO1Q`4UKw|`U#``U#+~W2zBoMQw-;A!I{enMkGAc8_rUcR|0&&7h0qV< zA3SEl@MZ%ulQQs3x6!NTFCt|>`#Eemyqmsu2h9)Nu zOH2xk9F;K??jeBawY45^A^fxEvt{SrJfhQ{XPaw(qMnEE`Y3nH5n35R?npXP;_#ia zi1+fVoC4((D5pR<1+?DNxqRd z0LSmI0vy}_8{kajJAm^Tzj*xKW7J4|lH=nTdFR|5$IFagyVooFjvT4Em9p+bRb03S z0xS#TW7>}uz4MuQ>t_rN%NR8@D{)NstYK+G2Vo+?Ig1>|x74?3*|bHAX6>4_Y}USg zn|5$2zsf04PJwa?lvALb0_7Aar$9Lc$|+DzfpQ9zQ=psz|9ceRIG2N0j+Z%J=UAQN zbgtWT&7SM^T%+f>nPYFR%X3YhV|sU z4w#h#4S83g z+=g*S2=3j0?m!QqC-5lH3*h(Oxc3420{wvgzyQDigaTneI1m9C0TVC~7z9KDgMlc3 z`x#*FrNub}_gEkfhzAk?Gmr={%_Q8DffRsqD8qo^zzBffZvp8=&wMBT%thP0QLrKj zM9I@}&w$FW^C1Gv}pIiGZ!jEyT2)Xxw@!F!{? z16i8wB+5-L=3Q4*ND5wgt9t z+Z5YRTLpf!?aeneyn65CCiwG&s`uYE#sTKWC&NP+(cf}9?i literal 0 HcmV?d00001 diff --git a/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/医疗器械注册申报资料和批准证明文件格式要求(体外诊断试剂).doc b/docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/医疗器械注册申报资料和批准证明文件格式要求(体外诊断试剂).doc new file mode 100644 index 0000000000000000000000000000000000000000..2fb652906f9db2a6e42147d0925c2dda5c38bbc0 GIT binary patch literal 31232 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 K Date: Fri, 5 Jun 2026 23:40:18 +0800 Subject: [PATCH 007/111] =?UTF-8?q?docs(requirements):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=B3=95=E8=A7=84=E6=A0=B8=E6=9F=A5=E6=95=B4=E6=94=B9?= =?UTF-8?q?=E9=97=AD=E7=8E=AF=E9=9C=80=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2.NMPA注册资料法规核查与整改闭环.md | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 docs/1.需求分析/2.NMPA注册资料法规核查与整改闭环.md 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 阶段优先覆盖产品名称、型号规格、预期用途、样本类型、储存条件 | From ce574048a4ee27415b50e5d1bd044fac453d5b04 Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 00:55:54 +0800 Subject: [PATCH 008/111] =?UTF-8?q?docs(regulatory-review):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=B3=95=E8=A7=84=E6=A0=B8=E6=9F=A5=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2.NMPA注册资料法规核查与整改闭环.md | 730 ++++++++++++++++++ .../2.NMPA注册资料法规核查与整改闭环.md | 666 ++++++++++++++++ .../2.NMPA注册资料法规核查与整改闭环.md | 485 ++++++++++++ 3 files changed, 1881 insertions(+) create mode 100644 docs/2.功能设计/2.NMPA注册资料法规核查与整改闭环.md create mode 100644 docs/3.详细设计/2.NMPA注册资料法规核查与整改闭环.md create mode 100644 docs/4.数据库设计/2.NMPA注册资料法规核查与整改闭环.md 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/3.详细设计/2.NMPA注册资料法规核查与整改闭环.md b/docs/3.详细设计/2.NMPA注册资料法规核查与整改闭环.md new file mode 100644 index 0000000..64d3d79 --- /dev/null +++ b/docs/3.详细设计/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.数据库设计/2.NMPA注册资料法规核查与整改闭环.md b/docs/4.数据库设计/2.NMPA注册资料法规核查与整改闭环.md new file mode 100644 index 0000000..788a1ed --- /dev/null +++ b/docs/4.数据库设计/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 | From b96ab1303ade6eee9dac8d4b23408bd4ca1e9a76 Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 00:56:41 +0800 Subject: [PATCH 009/111] =?UTF-8?q?docs(file-summary):=20=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E6=96=87=E6=A1=A3=E8=B7=AF=E5=BE=84=E5=92=8C=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E6=A8=A1=E5=9E=8B=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/2.功能设计/1.自动汇总.md | 10 +++++----- docs/3.详细设计/1.自动汇总.md | 4 ++-- docs/4.数据库设计/1.自动汇总.md | 6 +++--- docs/5.开发计划/1.自动汇总.md | 12 ++++++------ 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/2.功能设计/1.自动汇总.md b/docs/2.功能设计/1.自动汇总.md index d6bf121..4e3fcb4 100644 --- a/docs/2.功能设计/1.自动汇总.md +++ b/docs/2.功能设计/1.自动汇总.md @@ -4,7 +4,7 @@ | 项目 | 内容 | | --- | --- | -| 需求分析文档 | docs/需求分析/1.自动汇总.md | +| 需求分析文档 | docs/1.需求分析/1.自动汇总.md | | 功能名称 | 自动汇总文件夹文件目录与页数 | | 所属模块 | 审核智能体 review_agent | | 设计日期 | 2026-06-05 | @@ -137,7 +137,7 @@ WorkflowExecutor | 职责 | 接收对话页上传的压缩包或多个文件,保存原始文件,创建上传批次 | | 输入 | conversation_id、user_id、uploaded_files | | 输出 | batch_id、upload_file_ids、upload_type、original_storage_paths | -| 数据写入 | FileSummaryBatch、UploadedSourceFile | +| 数据写入 | FileSummaryBatch、FileAttachment、FileSummaryBatchAttachment | | 关键规则 | 文件必须绑定当前 Conversation;同一对话只使用本对话上传的文件 | ### 4.3 压缩包解压 Skill @@ -252,9 +252,9 @@ WorkflowExecutor | started_at | DateTimeField | 开始时间 | | finished_at | DateTimeField | 完成时间 | -### 5.2 UploadedSourceFile +### 5.2 FileAttachment -上传原始文件记录。 +上传原始文件记录。用户上传即存储为 `FileAttachment`,批次启动时再通过 `FileSummaryBatchAttachment` 固化本次使用的附件版本。 | 字段 | 类型 | 说明 | | --- | --- | --- | @@ -552,7 +552,7 @@ export_id -> batch -> conversation -> user | 设计点 | 说明 | | --- | --- | | 对话隔离 | 所有批次查询和下载必须校验 conversation.user | -| 防串文件 | 工作流只能读取当前 batch 绑定的 UploadedSourceFile | +| 防串文件 | 工作流只能读取当前 batch 通过 FileSummaryBatchAttachment 绑定的 FileAttachment | | 解压安全 | 禁止压缩包内路径跳出批次工作目录 | | 文件执行安全 | 不执行上传文件中的脚本、宏或外部链接 | | 下载权限 | 下载接口必须验证当前用户拥有批次所属对话 | diff --git a/docs/3.详细设计/1.自动汇总.md b/docs/3.详细设计/1.自动汇总.md index 36f468a..832534c 100644 --- a/docs/3.详细设计/1.自动汇总.md +++ b/docs/3.详细设计/1.自动汇总.md @@ -4,8 +4,8 @@ | 项目 | 内容 | | --- | --- | -| 需求分析文档 | docs/需求分析/1.自动汇总.md | -| 功能设计文档 | docs/功能设计/1.自动汇总.md | +| 需求分析文档 | docs/1.需求分析/1.自动汇总.md | +| 功能设计文档 | docs/2.功能设计/1.自动汇总.md | | 功能名称 | 自动汇总文件夹文件目录与页数 | | 所属模块 | 审核智能体 review_agent | | 设计日期 | 2026-06-05 | diff --git a/docs/4.数据库设计/1.自动汇总.md b/docs/4.数据库设计/1.自动汇总.md index 194c506..5ae7641 100644 --- a/docs/4.数据库设计/1.自动汇总.md +++ b/docs/4.数据库设计/1.自动汇总.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 需求分析文档 | docs/需求分析/1.自动汇总.md | -| 功能设计文档 | docs/功能设计/1.自动汇总.md | -| 详细设计文档 | docs/详细设计/1.自动汇总.md | +| 需求分析文档 | docs/1.需求分析/1.自动汇总.md | +| 功能设计文档 | docs/2.功能设计/1.自动汇总.md | +| 详细设计文档 | docs/3.详细设计/1.自动汇总.md | | 数据库类型 | SQLite / Django ORM | | 表名前缀 | ra_ | | 设计日期 | 2026-06-05 | diff --git a/docs/5.开发计划/1.自动汇总.md b/docs/5.开发计划/1.自动汇总.md index 86e4b96..bd4aee2 100644 --- a/docs/5.开发计划/1.自动汇总.md +++ b/docs/5.开发计划/1.自动汇总.md @@ -4,10 +4,10 @@ | 项目 | 内容 | | --- | --- | -| 需求分析文档 | docs/需求分析/1.自动汇总.md | -| 功能设计文档 | docs/功能设计/1.自动汇总.md | -| 详细设计文档 | docs/详细设计/1.自动汇总.md | -| 数据库设计文档 | docs/数据库设计/1.自动汇总.md | +| 需求分析文档 | docs/1.需求分析/1.自动汇总.md | +| 功能设计文档 | docs/2.功能设计/1.自动汇总.md | +| 详细设计文档 | docs/3.详细设计/1.自动汇总.md | +| 数据库设计文档 | docs/4.数据库设计/1.自动汇总.md | | 功能名称 | 自动汇总文件夹文件目录与页数 | | 所属模块 | 审核智能体 review_agent | | 执行方式 | 单人开发 + Codex 流水线自动化执行 | @@ -120,7 +120,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/数据库设计/1.自动汇总.md` 在 `review_agent/models.py` 新增 7 个 `ra_` 表模型,使用 Django ORM、TextChoices、短表名、索引和唯一约束。 | +| Codex 执行提示 | 请按 `docs/4.数据库设计/1.自动汇总.md` 在 `review_agent/models.py` 新增 7 个 `ra_` 表模型,使用 Django ORM、TextChoices、短表名、索引和唯一约束。 | ### FS-P1-002 生成并验证数据库迁移 @@ -616,7 +616,7 @@ 后续可直接对 Codex 输入: ```text -请按 docs/开发计划/1.自动汇总.md 执行,从 V2 创建 codex/YYYYMMDD-自动汇总文件目录页数 分支,按 P0 到 P7 顺序开发、验证和阶段提交。每个阶段完成后调用 git-commit-summary 生成提交摘要并本地提交。全部完成后合并回 V2,并重新运行总体验收。 +请按 docs/5.开发计划/1.自动汇总.md 执行,从 V2 创建 codex/YYYYMMDD-自动汇总文件目录页数 分支,按 P0 到 P7 顺序开发、验证和阶段提交。每个阶段完成后调用 git-commit-summary 生成提交摘要并本地提交。全部完成后合并回 V2,并重新运行总体验收。 ``` --- From 855afcdee3f82b3a88b8001279be548abe8f8cf9 Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 01:11:11 +0800 Subject: [PATCH 010/111] =?UTF-8?q?feat(file-summary):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=96=87=E4=BB=B6=E6=B1=87=E6=80=BB=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 + pytest.ini | 3 + requirements.txt | 7 + ...mmarybatch_exportedsummaryfile_and_more.py | 481 ++++++++++++++++++ review_agent/models.py | 290 +++++++++++ tests/test_file_summary_models.py | 113 ++++ 6 files changed, 906 insertions(+) create mode 100644 pytest.ini create mode 100644 review_agent/migrations/0002_fileattachment_filesummarybatch_exportedsummaryfile_and_more.py create mode 100644 tests/test_file_summary_models.py diff --git a/README.md b/README.md index de78a58..3f52755 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,15 @@ python manage.py runserver - 登录页:http://127.0.0.1:8000/login/ - 首页:http://127.0.0.1:8000/ - 管理后台:http://127.0.0.1:8000/admin/ + +## 文件汇总依赖 + +自动汇总文件目录与页数功能使用轻量 Python 库读取 PDF、Word、Excel、PowerPoint 文件。 +Docker 或生产环境如需处理 `.7z` 与 `.rar` 压缩包,还需要安装系统 `7z`/`p7zip` +命令,并确认以下命令可用: + +```bash +7z +``` + +LibreOffice 不是必需依赖,仅作为未来增强老格式文档解析的可选能力。 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..7a4fb9b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = config.settings +python_files = tests.py test_*.py *_tests.py diff --git a/requirements.txt b/requirements.txt index af9b7e1..f26a954 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,8 @@ 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 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/models.py b/review_agent/models.py index 46eba84..a5af82c 100644 --- a/review_agent/models.py +++ b/review_agent/models.py @@ -42,3 +42,293 @@ class Message(models.Model): 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", "执行中" + RETRYING = "retrying", "重试中" + SUCCESS = "success", "成功" + FAILED = "failed", "失败" + SKIPPED = "skipped", "跳过" + + batch = models.ForeignKey( + FileSummaryBatch, + on_delete=models.CASCADE, + related_name="node_runs", + ) + 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") + ] + indexes = [ + models.Index(fields=["batch", "status"], name="idx_ra_node_batch_status"), + ] + + +class WorkflowEvent(models.Model): + """Persists workflow events for SSE replay and diagnostics.""" + + batch = models.ForeignKey( + FileSummaryBatch, + on_delete=models.CASCADE, + related_name="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"), + ] + + +class ExportedSummaryFile(models.Model): + """Stores generated report files for permission-checked download.""" + + class ExportType(models.TextChoices): + MARKDOWN = "markdown", "Markdown" + EXCEL = "excel", "Excel" + + class Status(models.TextChoices): + SUCCESS = "success", "成功" + FAILED = "failed", "失败" + + batch = models.ForeignKey( + FileSummaryBatch, + on_delete=models.CASCADE, + related_name="exports", + ) + 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"), + ] 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 From eb87d9040d8516ba3e4b2e08ec3b4f9324c79870 Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 01:13:23 +0800 Subject: [PATCH 011/111] =?UTF-8?q?feat(file-summary):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E5=AF=B9=E8=AF=9D=E9=99=84=E4=BB=B6=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/urls.py | 3 +- review_agent/file_summary/__init__.py | 1 + review_agent/file_summary/constants.py | 4 ++ review_agent/file_summary/storage.py | 88 ++++++++++++++++++++++++++ review_agent/file_summary/views.py | 58 +++++++++++++++++ review_agent/urls.py | 22 +++++++ tests/test_file_summary_storage.py | 48 ++++++++++++++ tests/test_file_summary_views.py | 75 ++++++++++++++++++++++ 8 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 review_agent/file_summary/__init__.py create mode 100644 review_agent/file_summary/constants.py create mode 100644 review_agent/file_summary/storage.py create mode 100644 review_agent/file_summary/views.py create mode 100644 review_agent/urls.py create mode 100644 tests/test_file_summary_storage.py create mode 100644 tests/test_file_summary_views.py diff --git a/config/urls.py b/config/urls.py index ec39f6a..cd123c8 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,11 +1,12 @@ from django.contrib import admin from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView -from django.urls import path +from django.urls import include, path from review_agent.views import stream_chat, workspace urlpatterns = [ path("", workspace, name="home"), + path("", include("review_agent.urls")), path("chat/stream/", stream_chat, name="chat_stream"), path( "login/", 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/storage.py b/review_agent/file_summary/storage.py new file mode 100644 index 0000000..7c2a0c7 --- /dev/null +++ b/review_agent/file_summary/storage.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +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 + + +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) + 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) + + return 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 "", + ) + + +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..1b48924 --- /dev/null +++ b/review_agent/file_summary/views.py @@ -0,0 +1,58 @@ +from django.contrib.auth.decorators import login_required +from django.http import Http404, JsonResponse +from django.views.decorators.http import require_http_methods + +from review_agent.models import Conversation, FileAttachment + +from .storage import save_uploaded_attachment, serialize_attachment + + +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) + saved = [ + save_uploaded_attachment( + conversation=conversation, + user=request.user, + uploaded_file=uploaded_file, + ) + for uploaded_file in files + ] + return JsonResponse({"attachments": [serialize_attachment(item) for item in saved]}) + + queryset = FileAttachment.objects.filter(conversation=conversation).order_by( + "original_name", + "-version_no", + ) + return JsonResponse({"attachments": [serialize_attachment(item) for item in queryset]}) + + +@require_http_methods(["DELETE"]) +@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("附件不存在。") + + attachment.upload_status = FileAttachment.UploadStatus.DELETED + attachment.is_active = False + attachment.save(update_fields=["upload_status", "is_active"]) + return JsonResponse({"ok": True, "attachment": serialize_attachment(attachment)}) diff --git a/review_agent/urls.py b/review_agent/urls.py new file mode 100644 index 0000000..272291d --- /dev/null +++ b/review_agent/urls.py @@ -0,0 +1,22 @@ +from django.urls import path + +from .file_summary.views import attachment_detail, attachments + + +urlpatterns = [ + 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", + ), +] 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_views.py b/tests/test_file_summary_views.py new file mode 100644 index 0000000..bbf8745 --- /dev/null +++ b/tests/test_file_summary_views.py @@ -0,0 +1,75 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +import pytest + +from review_agent.models import Conversation, FileAttachment + + +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 From 51e7c0c007b38cf6cf9ad081f41ff1a66c2ef219 Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 01:16:22 +0800 Subject: [PATCH 012/111] =?UTF-8?q?feat(file-summary):=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=96=87=E4=BB=B6=E6=B1=87=E6=80=BB=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/file_summary/events.py | 16 +++ review_agent/file_summary/views.py | 49 +++++++ review_agent/file_summary/workflow.py | 127 ++++++++++++++++++ review_agent/file_summary/workflow_trigger.py | 30 +++++ review_agent/services.py | 49 +++++++ review_agent/urls.py | 12 +- tests/test_file_summary_trigger.py | 32 +++++ tests/test_file_summary_workflow.py | 102 ++++++++++++++ 8 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 review_agent/file_summary/events.py create mode 100644 review_agent/file_summary/workflow.py create mode 100644 review_agent/file_summary/workflow_trigger.py create mode 100644 tests/test_file_summary_trigger.py create mode 100644 tests/test_file_summary_workflow.py diff --git a/review_agent/file_summary/events.py b/review_agent/file_summary/events.py new file mode 100644 index 0000000..3d9f80c --- /dev/null +++ b/review_agent/file_summary/events.py @@ -0,0 +1,16 @@ +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, 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/views.py b/review_agent/file_summary/views.py index 1b48924..fa4d169 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -3,6 +3,8 @@ from django.http import Http404, JsonResponse from django.views.decorators.http import require_http_methods from review_agent.models import Conversation, FileAttachment +from review_agent.models import FileSummaryBatch, WorkflowEvent +from .events import serialize_event from .storage import save_uploaded_attachment, serialize_attachment @@ -56,3 +58,50 @@ def attachment_detail(request, conversation_id: int, attachment_id: int): attachment.is_active = False attachment.save(update_fields=["upload_status", "is_active"]) return JsonResponse({"ok": True, "attachment": serialize_attachment(attachment)}) + + +@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("批次不存在。") + return JsonResponse( + { + "batch": { + "id": batch.pk, + "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, + }, + "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") + ], + } + ) + + +@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]}) diff --git a/review_agent/file_summary/workflow.py b/review_agent/file_summary/workflow.py new file mode 100644 index 0000000..9316350 --- /dev/null +++ b/review_agent/file_summary/workflow.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from threading import Thread +from uuid import uuid4 + +from django.db import transaction +from django.utils import timezone + +from review_agent.models import ( + Conversation, + FileAttachment, + FileSummaryBatch, + FileSummaryBatchAttachment, + Message, + WorkflowNodeRun, +) + +from .events import record_event + + +NODE_DEFINITIONS = [ + ("upload", "附件固化"), + ("extract", "压缩包解压"), + ("inventory", "文件扫描"), + ("page_count", "页数统计"), + ("product_detect", "产品识别"), + ("report", "报告输出"), + ("complete", "完成"), +] + + +def build_batch_no() -> str: + return f"FS-{timezone.localtime().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6]}" + + +@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("当前对话没有可用附件。") + + batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + trigger_message=trigger_message, + batch_no=build_batch_no(), + ) + + for attachment in active_attachments: + FileSummaryBatchAttachment.objects.create(batch=batch, attachment=attachment) + attachment.upload_status = FileAttachment.UploadStatus.BOUND + attachment.save(update_fields=["upload_status"]) + + for code, name in NODE_DEFINITIONS: + WorkflowNodeRun.objects.create(batch=batch, node_code=code, node_name=name) + + record_event(batch, "workflow_created", {"batch_id": batch.pk, "batch_no": batch.batch_no}) + return batch + + +class WorkflowExecutor: + def __init__(self, batch: FileSummaryBatch): + self.batch = batch + + def run(self) -> None: + 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: + 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)}) + 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}) + + def _run_node(self, node: WorkflowNodeRun) -> None: + 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}, + ) + + 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}, + ) + + +def start_file_summary_workflow(batch: FileSummaryBatch, *, async_run: bool = True) -> None: + executor = WorkflowExecutor(batch) + if not async_run: + executor.run() + return + 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..ff86c41 --- /dev/null +++ b/review_agent/file_summary/workflow_trigger.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from review_agent.models import Conversation, FileAttachment + + +TRIGGER_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") diff --git a/review_agent/services.py b/review_agent/services.py index 43a3a2f..c4b352b 100644 --- a/review_agent/services.py +++ b/review_agent/services.py @@ -3,8 +3,11 @@ from __future__ import annotations import json from django.db.models import Q, QuerySet +from django.conf import settings from django.utils import timezone +from .file_summary.workflow import create_file_summary_batch, start_file_summary_workflow +from .file_summary.workflow_trigger import evaluate_file_summary_trigger from .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply from .models import Conversation, Message @@ -88,6 +91,7 @@ def stream_message(conversation: Conversation, content: str): user_message = append_user_message(conversation, content) assistant_parts: list[str] = [] + trigger = evaluate_file_summary_trigger(conversation, content) yield sse_event( "meta", @@ -99,6 +103,51 @@ def stream_message(conversation: Conversation, content: str): }, ) + if trigger.reason == "missing_attachment": + 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 trigger.should_start: + 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 + try: for chunk in stream_reply(conversation, content): assistant_parts.append(chunk) diff --git a/review_agent/urls.py b/review_agent/urls.py index 272291d..5f6fac3 100644 --- a/review_agent/urls.py +++ b/review_agent/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from .file_summary.views import attachment_detail, attachments +from .file_summary.views import attachment_detail, attachments, batch_events, batch_status urlpatterns = [ @@ -19,4 +19,14 @@ urlpatterns = [ attachment_detail, name="file_summary_attachment_detail", ), + 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", + ), ] diff --git a/tests/test_file_summary_trigger.py b/tests/test_file_summary_trigger.py new file mode 100644 index 0000000..4d94164 --- /dev/null +++ b/tests/test_file_summary_trigger.py @@ -0,0 +1,32 @@ +import pytest + +from review_agent.file_summary.workflow_trigger import 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" diff --git a/tests/test_file_summary_workflow.py b/tests/test_file_summary_workflow.py new file mode 100644 index 0000000..ea50817 --- /dev/null +++ b/tests/test_file_summary_workflow.py @@ -0,0 +1,102 @@ +import pytest + +from review_agent.file_summary.workflow import create_file_summary_batch, start_file_summary_workflow +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 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_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="会话") + + def fake_stream_reply(conversation, content): + yield "普通回复" + + monkeypatch.setattr("review_agent.services.stream_reply", fake_stream_reply) + + frames = list(stream_message(conversation, "你好")) + + joined = "".join(frames) + assert "普通回复" in joined + assert "workflow_started" not in joined From 18d045d4874c9c95c186fc2a970064f0f2aba333 Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 01:20:26 +0800 Subject: [PATCH 013/111] =?UTF-8?q?feat(file-summary):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E6=96=87=E4=BB=B6=E5=A4=84=E7=90=86=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/file_summary/paths.py | 12 +++ .../file_summary/services/__init__.py | 1 + review_agent/file_summary/services/archive.py | 77 +++++++++++++++++++ .../file_summary/services/inventory.py | 49 ++++++++++++ .../file_summary/services/page_count.py | 59 ++++++++++++++ .../file_summary/services/product_detect.py | 31 ++++++++ review_agent/file_summary/skills/__init__.py | 1 + .../file_summary/skills/archive_extract.py | 26 +++++++ review_agent/file_summary/skills/base.py | 24 ++++++ .../skills/document_page_count.py | 64 +++++++++++++++ .../file_summary/skills/file_inventory.py | 21 +++++ .../file_summary/skills/product_detect.py | 12 +++ review_agent/file_summary/skills/registry.py | 22 ++++++ review_agent/file_summary/workflow.py | 43 ++++++++--- tests/test_file_summary_archive.py | 25 ++++++ tests/test_file_summary_inventory.py | 24 ++++++ tests/test_file_summary_page_count.py | 66 ++++++++++++++++ tests/test_file_summary_product_detect.py | 29 +++++++ tests/test_file_summary_skills.py | 27 +++++++ 19 files changed, 604 insertions(+), 9 deletions(-) create mode 100644 review_agent/file_summary/paths.py create mode 100644 review_agent/file_summary/services/__init__.py create mode 100644 review_agent/file_summary/services/archive.py create mode 100644 review_agent/file_summary/services/inventory.py create mode 100644 review_agent/file_summary/services/page_count.py create mode 100644 review_agent/file_summary/services/product_detect.py create mode 100644 review_agent/file_summary/skills/__init__.py create mode 100644 review_agent/file_summary/skills/archive_extract.py create mode 100644 review_agent/file_summary/skills/base.py create mode 100644 review_agent/file_summary/skills/document_page_count.py create mode 100644 review_agent/file_summary/skills/file_inventory.py create mode 100644 review_agent/file_summary/skills/product_detect.py create mode 100644 review_agent/file_summary/skills/registry.py create mode 100644 tests/test_file_summary_archive.py create mode 100644 tests/test_file_summary_inventory.py create mode 100644 tests/test_file_summary_page_count.py create mode 100644 tests/test_file_summary_product_detect.py create mode 100644 tests/test_file_summary_skills.py 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..9e554e8 --- /dev/null +++ b/review_agent/file_summary/services/archive.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path +from zipfile import ZipFile + +import py7zr + + +ARCHIVE_EXTENSIONS = {"zip", "7z", "rar"} + + +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]: + 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/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..3a90b9b --- /dev/null +++ b/review_agent/file_summary/services/page_count.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +SUPPORTED_EXTENSIONS = {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx"} + + +@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": + from docx import Document + + properties = Document(str(file_path)).core_properties + pages = getattr(properties, "pages", None) + if pages: + return PageCountResult(status="success", page_count=pages) + return PageCountResult(status="uncertain") + if ext == "xlsx": + from openpyxl import load_workbook + + workbook = load_workbook(str(file_path), read_only=True, data_only=True) + return PageCountResult(status="success", page_count=len(workbook.sheetnames)) + if ext == "xls": + import xlrd + + workbook = xlrd.open_workbook(str(file_path), on_demand=True) + return PageCountResult(status="success", page_count=workbook.nsheets) + if ext == "pptx": + from pptx import Presentation + + return PageCountResult(status="success", page_count=len(Presentation(str(file_path)).slides)) + if ext in {"doc", "ppt"}: + import olefile + + if olefile.isOleFile(str(file_path)): + return PageCountResult(status="uncertain") + return PageCountResult(status="failed", error_message="不是有效的 OLE 文件。") + except Exception as exc: + return PageCountResult(status="failed", error_message=str(exc)) + + return PageCountResult(status="uncertain") 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/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..83487b8 --- /dev/null +++ b/review_agent/file_summary/skills/archive_extract.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from pathlib import Path + +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 + + +class ArchiveExtractSkill(BaseSkill): + name = "archive_extract" + + def run(self, context: WorkflowContext) -> SkillResult: + extracted_count = 0 + target_dir = Path(context.batch.work_dir or "") + if not target_dir: + return SkillResult(success=True, data={"extracted_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 + extracted_count += len(extract_archive(path, target_dir)) + return SkillResult(success=True, data={"extracted_count": extracted_count}) 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..f53ad77 --- /dev/null +++ b/review_agent/file_summary/skills/document_page_count.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from review_agent.models import FileSummaryItem + +from ..services.page_count import SUPPORTED_EXTENSIONS, count_document_pages +from .base import BaseSkill, SkillResult, WorkflowContext + + +class DocumentPageCountSkill(BaseSkill): + name = "document_page_count" + + def run(self, context: WorkflowContext) -> SkillResult: + success_files = failed_files = unsupported_files = uncertain_files = total_pages = 0 + 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"]) + continue + + result = None + for attempt in range(1, 4): + 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 + + 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", + ] + ) + 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..75a94dc --- /dev/null +++ b/review_agent/file_summary/skills/file_inventory.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from pathlib import Path + +from review_agent.models import FileSummaryBatchAttachment + +from ..paths import resolve_storage_path +from ..services.inventory import scan_files_to_items +from .base import BaseSkill, SkillResult, WorkflowContext + + +class FileInventorySkill(BaseSkill): + name = "file_inventory" + + def run(self, context: WorkflowContext) -> SkillResult: + roots = [ + resolve_storage_path(binding.attachment.storage_path) + for binding in FileSummaryBatchAttachment.objects.filter(batch=context.batch) + ] + items = scan_files_to_items(batch=context.batch, roots=roots) + 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..cf86b63 --- /dev/null +++ b/review_agent/file_summary/skills/product_detect.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from ..services.product_detect import detect_product_name +from .base import BaseSkill, SkillResult, WorkflowContext + + +class ProductDetectSkill(BaseSkill): + name = "product_detect" + + def run(self, context: WorkflowContext) -> SkillResult: + product_name = detect_product_name(context.batch) + 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..9dde1e7 --- /dev/null +++ b/review_agent/file_summary/skills/registry.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from .base import BaseSkill, SkillResult, WorkflowContext + + +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 + + 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: + return self.get(name).run(context) diff --git a/review_agent/file_summary/workflow.py b/review_agent/file_summary/workflow.py index 9316350..65b517f 100644 --- a/review_agent/file_summary/workflow.py +++ b/review_agent/file_summary/workflow.py @@ -16,19 +16,34 @@ from review_agent.models import ( ) from .events import record_event +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 NODE_DEFINITIONS = [ - ("upload", "附件固化"), - ("extract", "压缩包解压"), - ("inventory", "文件扫描"), - ("page_count", "页数统计"), - ("product_detect", "产品识别"), - ("report", "报告输出"), - ("complete", "完成"), + ("upload", "附件固化", ""), + ("extract", "压缩包解压", "archive_extract"), + ("inventory", "文件扫描", "file_inventory"), + ("page_count", "页数统计", "document_page_count"), + ("product_detect", "产品识别", "product_detect"), + ("report", "报告输出", ""), + ("complete", "完成", ""), ] +def default_skill_registry() -> SkillRegistry: + registry = SkillRegistry() + registry.register(ArchiveExtractSkill()) + registry.register(FileInventorySkill()) + registry.register(DocumentPageCountSkill()) + registry.register(ProductDetectSkill()) + return registry + + def build_batch_no() -> str: return f"FS-{timezone.localtime().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6]}" @@ -61,7 +76,7 @@ def create_file_summary_batch( attachment.upload_status = FileAttachment.UploadStatus.BOUND attachment.save(update_fields=["upload_status"]) - for code, name in NODE_DEFINITIONS: + for code, name, _skill_name in NODE_DEFINITIONS: WorkflowNodeRun.objects.create(batch=batch, node_code=code, node_name=name) record_event(batch, "workflow_created", {"batch_id": batch.pk, "batch_no": batch.batch_no}) @@ -69,8 +84,9 @@ def create_file_summary_batch( class WorkflowExecutor: - def __init__(self, batch: FileSummaryBatch): + def __init__(self, batch: FileSummaryBatch, registry: SkillRegistry | None = None): self.batch = batch + self.registry = registry or default_skill_registry() def run(self) -> None: self.batch.status = FileSummaryBatch.Status.RUNNING @@ -107,6 +123,15 @@ class WorkflowExecutor: {"node_code": node.node_code, "status": node.status, "progress": node.progress}, ) + skill_name = next( + (skill for code, _name, skill in NODE_DEFINITIONS if code == node.node_code), + "", + ) + if skill_name: + result = self.registry.execute(skill_name, WorkflowContext(batch=self.batch)) + if not result.success: + raise RuntimeError(result.message or f"{node.node_name}执行失败") + node.status = WorkflowNodeRun.Status.SUCCESS node.progress = 100 node.finished_at = timezone.now() 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_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_page_count.py b/tests/test_file_summary_page_count.py new file mode 100644 index 0000000..e3c6077 --- /dev/null +++ b/tests/test_file_summary_page_count.py @@ -0,0 +1,66 @@ +import pytest +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_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_skills.py b/tests/test_file_summary_skills.py new file mode 100644 index 0000000..a700155 --- /dev/null +++ b/tests/test_file_summary_skills.py @@ -0,0 +1,27 @@ +import pytest + +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} From 61bd31790b523990900110ba959d09ae3a4d6545 Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 01:22:49 +0800 Subject: [PATCH 014/111] =?UTF-8?q?feat(file-summary):=20=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=B1=87=E6=80=BB=E6=8A=A5=E5=91=8A=E5=92=8C=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file_summary/services/export_excel.py | 54 ++++++++++++ review_agent/file_summary/services/report.py | 65 +++++++++++++++ .../file_summary/skills/summary_report.py | 33 ++++++++ review_agent/file_summary/views.py | 21 ++++- review_agent/file_summary/workflow.py | 4 +- review_agent/urls.py | 7 +- tests/test_file_summary_report.py | 82 +++++++++++++++++++ tests/test_file_summary_views.py | 25 +++++- 8 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 review_agent/file_summary/services/export_excel.py create mode 100644 review_agent/file_summary/services/report.py create mode 100644 review_agent/file_summary/skills/summary_report.py create mode 100644 tests/test_file_summary_report.py 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..2b968f3 --- /dev/null +++ b/review_agent/file_summary/services/export_excel.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from pathlib import Path + +from openpyxl import Workbook + +from review_agent.models import ExportedSummaryFile, FileSummaryBatch + + +def _exports_dir(batch: FileSummaryBatch) -> Path: + root = Path(batch.work_dir or Path("media") / "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: + 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) + return ExportedSummaryFile.objects.create( + batch=batch, + export_type=ExportedSummaryFile.ExportType.EXCEL, + file_name=path.name, + storage_path=str(path), + ) diff --git a/review_agent/file_summary/services/report.py b/review_agent/file_summary/services/report.py new file mode 100644 index 0000000..78220f4 --- /dev/null +++ b/review_agent/file_summary/services/report.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from pathlib import Path + +from review_agent.models import ExportedSummaryFile, FileSummaryBatch + + +def _exports_dir(batch: FileSummaryBatch) -> Path: + root = Path(batch.work_dir or Path("media") / "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]: + 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, + export_type=ExportedSummaryFile.ExportType.MARKDOWN, + file_name=path.name, + storage_path=str(path), + ) + return exported, build_summary_table(batch) 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..3e0c043 --- /dev/null +++ b/review_agent/file_summary/skills/summary_report.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +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 + + +class SummaryReportSkill(BaseSkill): + name = "summary_report" + + def run(self, context: WorkflowContext) -> SkillResult: + 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, + ) + return SkillResult( + success=True, + data={"markdown_export_id": markdown_export.pk, "excel_export_id": excel_export.pk}, + ) diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py index fa4d169..6bee16e 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -1,8 +1,10 @@ from django.contrib.auth.decorators import login_required -from django.http import Http404, JsonResponse +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 Conversation, FileAttachment +from review_agent.models import Conversation, ExportedSummaryFile, FileAttachment from review_agent.models import FileSummaryBatch, WorkflowEvent from .events import serialize_event @@ -105,3 +107,18 @@ def batch_events(request, batch_id: int): 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 = ExportedSummaryFile.objects.filter( + pk=export_id, + batch__user=request.user, + ).first() + if not exported: + raise Http404("导出文件不存在。") + path = Path(exported.storage_path) + if not path.exists(): + return JsonResponse({"error": "文件不存在。"}, status=404) + return FileResponse(path.open("rb"), as_attachment=True, filename=exported.file_name) diff --git a/review_agent/file_summary/workflow.py b/review_agent/file_summary/workflow.py index 65b517f..050ee88 100644 --- a/review_agent/file_summary/workflow.py +++ b/review_agent/file_summary/workflow.py @@ -22,6 +22,7 @@ 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 = [ @@ -30,7 +31,7 @@ NODE_DEFINITIONS = [ ("inventory", "文件扫描", "file_inventory"), ("page_count", "页数统计", "document_page_count"), ("product_detect", "产品识别", "product_detect"), - ("report", "报告输出", ""), + ("report", "报告输出", "summary_report"), ("complete", "完成", ""), ] @@ -41,6 +42,7 @@ def default_skill_registry() -> SkillRegistry: registry.register(FileInventorySkill()) registry.register(DocumentPageCountSkill()) registry.register(ProductDetectSkill()) + registry.register(SummaryReportSkill()) return registry diff --git a/review_agent/urls.py b/review_agent/urls.py index 5f6fac3..737071d 100644 --- a/review_agent/urls.py +++ b/review_agent/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from .file_summary.views import attachment_detail, attachments, batch_events, batch_status +from .file_summary.views import attachment_detail, attachments, batch_events, batch_status, export_download urlpatterns = [ @@ -29,4 +29,9 @@ urlpatterns = [ batch_events, name="file_summary_batch_events", ), + path( + "api/review-agent/file-summary/exports//download/", + export_download, + name="file_summary_export_download", + ), ] 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_views.py b/tests/test_file_summary_views.py index bbf8745..eeff753 100644 --- a/tests/test_file_summary_views.py +++ b/tests/test_file_summary_views.py @@ -2,7 +2,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse import pytest -from review_agent.models import Conversation, FileAttachment +from review_agent.models import Conversation, ExportedSummaryFile, FileAttachment, FileSummaryBatch pytestmark = pytest.mark.django_db @@ -73,3 +73,26 @@ def test_delete_attachment_is_logical_and_scoped(client, settings, tmp_path, dja 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 From a917a18ca1f01cb3321a9d081856e7596128391a Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 10:25:11 +0800 Subject: [PATCH 015/111] =?UTF-8?q?feat(file-summary):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=89=8D=E7=AB=AF=E6=B1=87=E6=80=BB=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/5.开发计划/1.自动汇总-前端线框图.md | 74 +++++++++ review_agent/views.py | 3 + static/css/login.css | 180 ++++++++++++++++++++- static/js/app.js | 189 ++++++++++++++++++++++- templates/home.html | 68 ++++++++ tests/test_file_summary_frontend.py | 22 +++ 6 files changed, 529 insertions(+), 7 deletions(-) create mode 100644 docs/5.开发计划/1.自动汇总-前端线框图.md create mode 100644 tests/test_file_summary_frontend.py 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/review_agent/views.py b/review_agent/views.py index e384834..a2aa67e 100644 --- a/review_agent/views.py +++ b/review_agent/views.py @@ -10,6 +10,7 @@ from .services import ( send_message, stream_message, ) +from .models import FileAttachment, FileSummaryBatch @login_required @@ -49,6 +50,8 @@ def workspace(request: HttpRequest) -> HttpResponse: "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 [], + "summary_batches": FileSummaryBatch.objects.filter(conversation=current).prefetch_related("node_runs").order_by("-created_at")[:5] if current else [], }, ) diff --git a/static/css/login.css b/static/css/login.css index 7f4f93f..3162919 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -127,7 +127,7 @@ input:focus { .workspace { display: grid; - grid-template-columns: 296px minmax(0, 1fr); + grid-template-columns: 296px minmax(0, 1fr) 340px; min-height: 100vh; } @@ -760,9 +760,176 @@ input:focus { padding-right: 12px; } +.summary-panel { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 14px; + min-width: 0; + max-height: 100vh; + 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 { + color: var(--muted); + font-size: 12px; +} + +.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; +} + +.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; +} + +.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; +} + +.message-bubble th, +.message-bubble td { + padding: 8px; + border: 1px solid var(--line); + text-align: left; + vertical-align: top; +} + @media (max-width: 980px) { .workspace { grid-template-columns: minmax(0, 1fr); + min-height: 100vh; + overflow: auto; } .sidebar { @@ -815,7 +982,14 @@ input:focus { } .chat-stage { - height: calc(100vh - 88px); + min-height: calc(100vh - 88px); + height: auto; + } + + .summary-panel { + max-height: none; + border-left: 0; + border-top: 1px solid var(--line); } .chat-scroll { @@ -889,7 +1063,7 @@ input:focus { width: 20px; } -.node-dot { + .node-dot { width: 10px; height: 10px; } diff --git a/static/js/app.js b/static/js/app.js index 1c3ee89..e8d2155 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -11,6 +11,12 @@ 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 = []; if (!workspace) { @@ -32,7 +38,7 @@ function syncSidebarState() { if (isMobile()) { - if (workspace.getAttribute("data-sidebar-state") === "collapsed") { + if (workspace.getAttribute("data-sidebar-state") !== "closed") { workspace.setAttribute("data-sidebar-state", "closed"); } } else if (workspace.getAttribute("data-sidebar-state") === "closed") { @@ -147,6 +153,13 @@ return escapeHtml(text).replace(/\n/g, "
"); } + function renderAssistantContent(text) { + if (window.marked && window.DOMPurify) { + return window.DOMPurify.sanitize(window.marked.parse(text || "")); + } + return nl2br(text || ""); + } + function scrollChatToBottom() { if (chatScroll) { chatScroll.scrollTop = chatScroll.scrollHeight; @@ -169,7 +182,7 @@ bubble.className = "message-bubble"; var text = document.createElement("p"); - text.innerHTML = nl2br(content); + text.innerHTML = role === "assistant" ? renderAssistantContent(content) : nl2br(content); bubble.appendChild(text); article.appendChild(avatar); @@ -271,6 +284,149 @@ } } + function currentConversationId() { + return conversationIdInput ? conversationIdInput.value : ""; + } + + function templateUrl(attributeName, token, value) { + if (!summaryPanel) { + return ""; + } + return summaryPanel.getAttribute(attributeName).replace(token, value); + } + + 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 card = workflowCardList.querySelector('[data-batch-id="' + batch.batch_id + '"]'); + if (card) { + return card; + } + card = document.createElement("article"); + card.className = "workflow-card"; + card.setAttribute("data-batch-id", batch.batch_id); + card.innerHTML = + "
" + + escapeHtml(batch.batch_no || "文件汇总") + + 'running
    '; + workflowCardList.prepend(card); + return card; + } + + async function refreshWorkflowCard(batchId) { + if (!summaryPanel || !batchId) { + return; + } + var response = await fetch(templateUrl("data-status-url-template", "__batch_id__", batchId)); + if (!response.ok) { + return; + } + var payload = await response.json(); + var card = ensureWorkflowCard({ + batch_id: payload.batch.id, + batch_no: payload.batch.batch_no, + }); + if (!card) { + return; + } + var status = card.querySelector(".workflow-status"); + status.textContent = payload.batch.status; + status.className = "workflow-status status-" + payload.batch.status; + 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.progress + "%"; + list.appendChild(item); + }); + } + async function streamChat(event) { event.preventDefault(); if (!composer || !promptInput || !sendButton || !chatStage) { @@ -356,11 +512,14 @@ } } else if (eventName === "chunk") { assistantText += payload.delta || ""; - assistantMessage.text.innerHTML = nl2br(assistantText); + assistantMessage.text.innerHTML = renderAssistantContent(assistantText); scrollChatToBottom(); } else if (eventName === "error") { assistantText = payload.message || "模型调用失败。"; - assistantMessage.text.innerHTML = nl2br(assistantText); + assistantMessage.text.innerHTML = renderAssistantContent(assistantText); + } else if (eventName === "workflow_started") { + ensureWorkflowCard(payload); + refreshWorkflowCard(payload.batch_id); } else if (eventName === "done") { if (payload.assistant_message_id) { assistantMessage.article.id = "message-" + payload.assistant_message_id; @@ -400,6 +559,28 @@ composer.addEventListener("submit", streamChat); } + 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/templates/home.html b/templates/home.html index 88c8c26..87f9ccd 100644 --- a/templates/home.html +++ b/templates/home.html @@ -164,9 +164,77 @@
    + + {% endblock %} {% block scripts %} + + {% endblock %} diff --git a/tests/test_file_summary_frontend.py b/tests/test_file_summary_frontend.py new file mode 100644 index 0000000..71d0318 --- /dev/null +++ b/tests/test_file_summary_frontend.py @@ -0,0 +1,22 @@ +import pytest +from django.urls import reverse + +from review_agent.models import Conversation + + +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="会话") + client.force_login(user) + + response = client.get(f"{reverse('home')}?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 "自动汇总文件目录与页数" in content From 684682f86dcefe793aac9f78c24f887da6120e12 Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 10:27:23 +0800 Subject: [PATCH 016/111] =?UTF-8?q?docs(file-summary):=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E9=83=A8=E7=BD=B2=E5=AD=98=E5=82=A8=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 3f52755..c4cc26f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,11 @@ Docker 或生产环境如需处理 `.7z` 与 `.rar` 压缩包,还需要安装 ```bash 7z +7z i ``` LibreOffice 不是必需依赖,仅作为未来增强老格式文档解析的可选能力。 + +上传原始文件、批次工作目录和导出文件默认存储在 Django `MEDIA_ROOT` 下的 +`file_summary/users///` 或批次 `work_dir` 目录中。生产环境 +需要把 `MEDIA_ROOT` 挂载到持久化卷,并纳入备份或归档策略。 From 311eb1b129f92a0e6599673ad339ec6a266b606b Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 10:32:18 +0800 Subject: [PATCH 017/111] =?UTF-8?q?test(file-summary):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20Playwright=20=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + requirements.txt | 1 + tests/test_file_summary_e2e.py | 47 ++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 tests/test_file_summary_e2e.py diff --git a/.gitignore b/.gitignore index e652b65..28c1048 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ db.sqlite3 staticfiles/ media/ .pytest_cache/ +.tmp/ .idea/ diff --git a/requirements.txt b/requirements.txt index f26a954..f257506 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ openpyxl>=3.1 xlrd>=2.0 olefile>=0.47 py7zr>=0.21 +playwright>=1.60 diff --git a/tests/test_file_summary_e2e.py b/tests/test_file_summary_e2e.py new file mode 100644 index 0000000..4275e4b --- /dev/null +++ b/tests/test_file_summary_e2e.py @@ -0,0 +1,47 @@ +from pathlib import Path + +import pytest + +from review_agent.models import Conversation + + +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.objects.create(user=user, title="E2E 会话") + + 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() + + 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() From b1a336d019c7c2577517e3c93dec7c8b7bd958ad Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 16:37:29 +0800 Subject: [PATCH 018/111] =?UTF-8?q?fix(file-summary):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=AF=BC=E5=87=BA=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/settings.py | 2 ++ review_agent/file_summary/services/export_excel.py | 3 ++- review_agent/file_summary/services/report.py | 4 +++- review_agent/file_summary/views.py | 12 +++++++++++- tests/test_file_summary_views.py | 3 +++ 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/config/settings.py b/config/settings.py index 11511b5..a4f9fae 100644 --- a/config/settings.py +++ b/config/settings.py @@ -92,6 +92,8 @@ USE_TZ = True STATIC_URL = "static/" STATICFILES_DIRS = [BASE_DIR / "static"] +MEDIA_ROOT = BASE_DIR / "media" +MEDIA_URL = "media/" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/review_agent/file_summary/services/export_excel.py b/review_agent/file_summary/services/export_excel.py index 2b968f3..b09a6a7 100644 --- a/review_agent/file_summary/services/export_excel.py +++ b/review_agent/file_summary/services/export_excel.py @@ -2,13 +2,14 @@ from __future__ import annotations from pathlib import Path +from django.conf import settings from openpyxl import Workbook from review_agent.models import ExportedSummaryFile, FileSummaryBatch def _exports_dir(batch: FileSummaryBatch) -> Path: - root = Path(batch.work_dir or Path("media") / "file_summary" / batch.batch_no) + 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 diff --git a/review_agent/file_summary/services/report.py b/review_agent/file_summary/services/report.py index 78220f4..0da3f4f 100644 --- a/review_agent/file_summary/services/report.py +++ b/review_agent/file_summary/services/report.py @@ -2,11 +2,13 @@ from __future__ import annotations from pathlib import Path +from django.conf import settings + from review_agent.models import ExportedSummaryFile, FileSummaryBatch def _exports_dir(batch: FileSummaryBatch) -> Path: - root = Path(batch.work_dir or Path("media") / "file_summary" / batch.batch_no) + 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 diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py index 6bee16e..c32a688 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -121,4 +121,14 @@ def export_download(request, export_id: int): path = Path(exported.storage_path) if not path.exists(): return JsonResponse({"error": "文件不存在。"}, status=404) - return FileResponse(path.open("rb"), as_attachment=True, filename=exported.file_name) + content_type = ( + "text/markdown; charset=utf-8" + if exported.export_type == ExportedSummaryFile.ExportType.MARKDOWN + else "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + return FileResponse( + path.open("rb"), + as_attachment=True, + filename=exported.file_name, + content_type=content_type, + ) diff --git a/tests/test_file_summary_views.py b/tests/test_file_summary_views.py index eeff753..588d466 100644 --- a/tests/test_file_summary_views.py +++ b/tests/test_file_summary_views.py @@ -96,3 +96,6 @@ def test_export_download_requires_batch_owner(client, tmp_path, django_user_mode 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") From fd88ff4652a5bc1744e1ec64f9585aadad5042e2 Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 16:37:40 +0800 Subject: [PATCH 019/111] =?UTF-8?q?fix(frontend):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E9=A1=B5=E5=B8=83=E5=B1=80=E4=B8=8E=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/css/login.css | 104 +++++++++++++++++++++------ static/js/app.js | 102 ++++++++++++++++++++++++++- templates/home.html | 105 +++++++++++++++------------- tests/test_file_summary_e2e.py | 17 ++++- tests/test_file_summary_frontend.py | 9 ++- 5 files changed, 261 insertions(+), 76 deletions(-) diff --git a/static/css/login.css b/static/css/login.css index 3162919..ea762c6 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -125,10 +125,20 @@ input:focus { 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: 100vh; + min-height: 0; + height: 100%; + overflow: hidden; } .sidebar { @@ -136,6 +146,8 @@ input:focus { flex-direction: column; gap: 24px; padding: 18px; + min-height: 0; + overflow-y: auto; 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; @@ -146,6 +158,12 @@ input:focus { gap: 14px; } +.sidebar-header { + display: flex; + align-items: center; + gap: 12px; +} + .brand { display: flex; align-items: center; @@ -310,8 +328,9 @@ input:focus { .chat-shell { display: grid; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); min-width: 0; + min-height: 0; padding: 0; } @@ -322,6 +341,8 @@ input:focus { gap: 16px; padding: 0 24px; min-height: 60px; + position: relative; + z-index: 30; border-bottom: 1px solid var(--line); background: #ffffff; } @@ -470,7 +491,7 @@ input:focus { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; - height: calc(100vh - 60px); + height: 100%; background: #ffffff; overflow: hidden; } @@ -562,8 +583,26 @@ input:focus { line-height: 1.7; } -.message-bubble p { +.message-bubble p, +.message-content p { margin: 0; + line-height: 1.8; +} + +.message-content { + display: grid; + gap: 14px; + line-height: 1.8; +} + +.message-content a { + color: var(--accent); + font-weight: 700; + text-decoration: none; +} + +.message-content a:hover { + text-decoration: underline; } .message-bubble.streaming { @@ -737,7 +776,7 @@ input:focus { } .workspace[data-sidebar-state="collapsed"] { - grid-template-columns: 88px minmax(0, 1fr); + grid-template-columns: 88px minmax(0, 1fr) 340px; } .workspace[data-sidebar-state="collapsed"] .brand-text, @@ -760,12 +799,20 @@ input:focus { 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: 100vh; + max-height: 100%; padding: 16px; overflow: auto; border-left: 1px solid var(--line); @@ -915,6 +962,7 @@ input:focus { width: 100%; border-collapse: collapse; font-size: 13px; + line-height: 1.6; } .message-bubble th, @@ -926,15 +974,26 @@ input:focus { } @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); - min-height: 100vh; - overflow: auto; + height: auto; + min-height: 0; + overflow: visible; } .sidebar { position: fixed; - inset: 0 auto 0 0; + inset: 60px auto 0 0; width: 280px; z-index: 20; box-shadow: var(--shadow); @@ -953,10 +1012,6 @@ input:focus { display: inline-flex; } - .sidebar-toggle { - display: none; - } - .topbar, .chat-scroll, .composer-wrap { @@ -965,16 +1020,22 @@ input:focus { } .topbar { - align-items: flex-start; - flex-direction: column; - min-height: auto; - padding-top: 12px; + 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 { - width: 100%; - justify-content: space-between; + flex: 0 0 auto; + width: auto; + justify-content: flex-end; } .conversation-header { @@ -982,7 +1043,7 @@ input:focus { } .chat-stage { - min-height: calc(100vh - 88px); + min-height: calc(100vh - 60px); height: auto; } @@ -1050,7 +1111,8 @@ input:focus { } .chat-stage { - height: calc(100vh - 126px); + min-height: calc(100vh - 60px); + height: auto; } .chat-scroll { diff --git a/static/js/app.js b/static/js/app.js index e8d2155..a87c4b2 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -153,11 +153,105 @@ 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) { if (window.marked && window.DOMPurify) { return window.DOMPurify.sanitize(window.marked.parse(text || "")); } - return nl2br(text || ""); + return renderBasicMarkdown(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() { @@ -181,7 +275,10 @@ var bubble = document.createElement("div"); bubble.className = "message-bubble"; - var text = document.createElement("p"); + 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); @@ -549,6 +646,7 @@ syncNodeRailVisibility(); bindNodeAnchorClicks(); + renderExistingAssistantMessages(); if (chatScroll) { chatScroll.addEventListener("scroll", setActiveNode, { passive: true }); diff --git a/templates/home.html b/templates/home.html index 87f9ccd..e88a4d7 100644 --- a/templates/home.html +++ b/templates/home.html @@ -5,18 +5,56 @@ {% block body_class %}app-body{% endblock %} {% block content %} -
    +
    +
    +
    +
    + + + + +
    +
    + +
    +
    + + +
    +
    +
    + +
    -
    -
    - -
    - - - - -
    -
    - -
    -
    - - -
    -
    -
    -
    @@ -114,7 +113,12 @@ {% if message.role == "assistant" %}AI{% else %}{{ request.user.username|slice:":1"|upper }}{% endif %}
    -

    {{ message.content|linebreaksbr }}

    + {% if message.role == "assistant" %} +
    + + {% else %} +

    {{ message.content|linebreaksbr }}

    + {% endif %}
    {% endfor %} @@ -230,6 +234,7 @@
    +
    {% endblock %} diff --git a/tests/test_file_summary_e2e.py b/tests/test_file_summary_e2e.py index 4275e4b..99baddb 100644 --- a/tests/test_file_summary_e2e.py +++ b/tests/test_file_summary_e2e.py @@ -2,7 +2,7 @@ from pathlib import Path import pytest -from review_agent.models import Conversation +from review_agent.models import Conversation, Message pytestmark = pytest.mark.django_db @@ -26,7 +26,18 @@ def test_file_summary_panel_desktop_and_mobile_with_playwright(live_server, djan 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.objects.create(user=user, title="E2E 会话") + 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) @@ -40,6 +51,8 @@ def test_file_summary_panel_desktop_and_mobile_with_playwright(live_server, djan 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() diff --git a/tests/test_file_summary_frontend.py b/tests/test_file_summary_frontend.py index 71d0318..a638aa8 100644 --- a/tests/test_file_summary_frontend.py +++ b/tests/test_file_summary_frontend.py @@ -1,7 +1,7 @@ import pytest from django.urls import reverse -from review_agent.models import Conversation +from review_agent.models import Conversation, Message pytestmark = pytest.mark.django_db @@ -10,6 +10,11 @@ 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('home')}?conversation={conversation.pk}") @@ -19,4 +24,6 @@ def test_workspace_renders_summary_panel(client, django_user_model): assert 'id="summaryPanel"' in content assert 'id="uploadDropzone"' in content assert 'id="workflowCardList"' in content + assert 'class="message-content markdown-content"' in content + assert 'class="message-raw"' in content assert "自动汇总文件目录与页数" in content From 47b5ad105489a1f3a21e4484fb4a2bd0e463f9a8 Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 16:37:54 +0800 Subject: [PATCH 020/111] =?UTF-8?q?feat(attachments):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E9=98=85=E8=AF=BB=E8=A7=A3=E6=9E=90=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/attachment_reader.py | 184 ++++++++++++++++++ .../file_summary/skills/attachment_reader.py | 31 +++ review_agent/file_summary/workflow_trigger.py | 28 +++ review_agent/services.py | 95 ++++++++- tests/test_attachment_reader.py | 111 +++++++++++ tests/test_file_summary_workflow.py | 24 +++ 6 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 review_agent/file_summary/services/attachment_reader.py create mode 100644 review_agent/file_summary/skills/attachment_reader.py create mode 100644 tests/test_attachment_reader.py 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..4f629aa --- /dev/null +++ b/review_agent/file_summary/services/attachment_reader.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import csv +from dataclasses import asdict, dataclass, field +from pathlib import Path + +from django.conf import settings + +from review_agent.models import FileAttachment + + +TEXT_EXTENSIONS = {"txt", "md", "csv", "json", "log"} +SUPPORTED_EXTENSIONS = TEXT_EXTENSIONS | {"pdf", "docx", "xlsx", "pptx"} +MAX_PREVIEW_CHARS = 3000 +MAX_ROWS_PER_SHEET = 20 + + +@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(".") + + if not file_path.exists(): + return _failed(attachment, file_type, "附件文件不存在。") + if file_type not in SUPPORTED_EXTENSIONS: + 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) + else: + sections = _read_text(file_path) + except Exception as exc: + return _failed(attachment, file_type, str(exc)) + + preview = _build_preview(sections) + 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 _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/skills/attachment_reader.py b/review_agent/file_summary/skills/attachment_reader.py new file mode 100644 index 0000000..3ce5cff --- /dev/null +++ b/review_agent/file_summary/skills/attachment_reader.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +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 + + +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: + results = [read_attachment_details(attachment).to_dict() for attachment in attachments] + if not results: + return SkillResult(success=False, message="当前对话没有可读取的附件。") + + has_success = any(item["status"] == "success" for item in results) + return SkillResult( + success=has_success, + data={"attachments": results}, + message="附件解析完成。" if has_success else "附件解析失败。", + ) diff --git a/review_agent/file_summary/workflow_trigger.py b/review_agent/file_summary/workflow_trigger.py index ff86c41..8e1722e 100644 --- a/review_agent/file_summary/workflow_trigger.py +++ b/review_agent/file_summary/workflow_trigger.py @@ -6,6 +6,19 @@ from review_agent.models import Conversation, FileAttachment TRIGGER_KEYWORDS = ("自动汇总", "文件目录", "页数", "目录与页数", "文件清单") +ATTACHMENT_READER_KEYWORDS = ( + "阅读附件", + "读取附件", + "解析附件", + "分析附件", + "查看附件", + "附件详情", + "文件详情", + "总结附件", + "总结文件", + "分析这个文件", + "阅读这个文件", +) @dataclass(frozen=True) @@ -28,3 +41,18 @@ def evaluate_file_summary_trigger(conversation: Conversation, content: str) -> T 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() + if not any(keyword in text for keyword in ATTACHMENT_READER_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="attachment_reader") diff --git a/review_agent/services.py b/review_agent/services.py index c4b352b..f29880f 100644 --- a/review_agent/services.py +++ b/review_agent/services.py @@ -6,10 +6,14 @@ 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 .file_summary.workflow_trigger import evaluate_file_summary_trigger +from .file_summary.workflow_trigger import ( + evaluate_attachment_reader_trigger, + evaluate_file_summary_trigger, +) from .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply -from .models import Conversation, Message +from .models import Conversation, FileAttachment, Message def list_conversations(user, search: str = "") -> QuerySet[Conversation]: @@ -92,6 +96,7 @@ def stream_message(conversation: Conversation, content: str): user_message = append_user_message(conversation, content) assistant_parts: list[str] = [] trigger = evaluate_file_summary_trigger(conversation, content) + attachment_reader_trigger = evaluate_attachment_reader_trigger(conversation, content) yield sse_event( "meta", @@ -117,6 +122,36 @@ def stream_message(conversation: Conversation, content: str): ) return + if attachment_reader_trigger.reason == "missing_attachment": + 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 attachment_reader_trigger.should_start: + attachments = _select_attachments_for_reader(conversation, content) + 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 trigger.should_start: batch = create_file_summary_batch( conversation=conversation, @@ -182,6 +217,62 @@ def build_conversation_title(content: str) -> str: return normalized[:24] +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 _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.""" diff --git a/tests/test_attachment_reader.py b/tests/test_attachment_reader.py new file mode 100644 index 0000000..147f889 --- /dev/null +++ b/tests/test_attachment_reader.py @@ -0,0 +1,111 @@ +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"] diff --git a/tests/test_file_summary_workflow.py b/tests/test_file_summary_workflow.py index ea50817..57534a5 100644 --- a/tests/test_file_summary_workflow.py +++ b/tests/test_file_summary_workflow.py @@ -100,3 +100,27 @@ def test_stream_message_uses_normal_llm_path_when_not_triggered(monkeypatch, dja joined = "".join(frames) assert "普通回复" in joined assert "workflow_started" not in joined + + +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 From fa77c68d774a089f5fd975f9161ccf9e0e20af23 Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 17:56:41 +0800 Subject: [PATCH 021/111] =?UTF-8?q?feat(agent):=20=E5=A2=9E=E5=8A=A0=20LLM?= =?UTF-8?q?=20=E8=B7=AF=E7=94=B1=E4=B8=8E=E8=AF=8A=E6=96=AD=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/settings.py | 23 +++ .../services/attachment_reader.py | 35 ++++ .../file_summary/services/export_excel.py | 12 +- review_agent/file_summary/services/report.py | 9 + .../file_summary/skills/archive_extract.py | 20 ++ .../file_summary/skills/attachment_reader.py | 23 ++- .../skills/document_page_count.py | 44 ++++ .../file_summary/skills/file_inventory.py | 16 ++ .../file_summary/skills/product_detect.py | 10 + review_agent/file_summary/skills/registry.py | 24 ++- .../file_summary/skills/summary_report.py | 14 ++ review_agent/file_summary/storage.py | 26 ++- review_agent/file_summary/views.py | 41 ++++ review_agent/file_summary/workflow.py | 45 +++++ review_agent/file_summary/workflow_trigger.py | 33 ++- review_agent/llm.py | 41 ++++ review_agent/services.py | 78 +++++++- review_agent/skill_router.py | 189 ++++++++++++++++++ tests/test_file_summary_skills.py | 19 ++ tests/test_file_summary_trigger.py | 43 +++- tests/test_file_summary_workflow.py | 104 ++++++++++ 21 files changed, 832 insertions(+), 17 deletions(-) create mode 100644 review_agent/skill_router.py diff --git a/config/settings.py b/config/settings.py index a4f9fae..a2260fa 100644 --- a/config/settings.py +++ b/config/settings.py @@ -104,3 +104,26 @@ 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", "") + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "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, + }, + }, +} diff --git a/review_agent/file_summary/services/attachment_reader.py b/review_agent/file_summary/services/attachment_reader.py index 4f629aa..8f7cbb5 100644 --- a/review_agent/file_summary/services/attachment_reader.py +++ b/review_agent/file_summary/services/attachment_reader.py @@ -1,6 +1,7 @@ from __future__ import annotations import csv +import logging from dataclasses import asdict, dataclass, field from pathlib import Path @@ -15,6 +16,9 @@ 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 @@ -32,10 +36,29 @@ class AttachmentReadResult: 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: @@ -52,9 +75,21 @@ def read_attachment_details(attachment: FileAttachment) -> AttachmentReadResult: 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, diff --git a/review_agent/file_summary/services/export_excel.py b/review_agent/file_summary/services/export_excel.py index b09a6a7..b5b370d 100644 --- a/review_agent/file_summary/services/export_excel.py +++ b/review_agent/file_summary/services/export_excel.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path from django.conf import settings @@ -8,6 +9,9 @@ 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" @@ -16,6 +20,7 @@ def _exports_dir(batch: FileSummaryBatch) -> Path: 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 = "汇总信息" @@ -47,9 +52,14 @@ def generate_excel_export(batch: FileSummaryBatch) -> ExportedSummaryFile: path = _exports_dir(batch) / f"{batch.batch_no}-summary.xlsx" workbook.save(path) - return ExportedSummaryFile.objects.create( + exported = ExportedSummaryFile.objects.create( batch=batch, 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/report.py b/review_agent/file_summary/services/report.py index 0da3f4f..a1f9fc9 100644 --- a/review_agent/file_summary/services/report.py +++ b/review_agent/file_summary/services/report.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path from django.conf import settings @@ -7,6 +8,9 @@ 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" @@ -55,6 +59,7 @@ def build_markdown_report(batch: FileSummaryBatch) -> str: 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") @@ -64,4 +69,8 @@ def generate_markdown_report(batch: FileSummaryBatch) -> tuple[ExportedSummaryFi 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/archive_extract.py b/review_agent/file_summary/skills/archive_extract.py index 83487b8..6e12f6f 100644 --- a/review_agent/file_summary/skills/archive_extract.py +++ b/review_agent/file_summary/skills/archive_extract.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path from review_agent.models import FileSummaryBatchAttachment @@ -9,6 +10,9 @@ from ..services.archive import ARCHIVE_EXTENSIONS, extract_archive from .base import BaseSkill, SkillResult, WorkflowContext +logger = logging.getLogger("review_agent.file_summary.skills.archive_extract") + + class ArchiveExtractSkill(BaseSkill): name = "archive_extract" @@ -16,11 +20,27 @@ class ArchiveExtractSkill(BaseSkill): extracted_count = 0 target_dir = Path(context.batch.work_dir or "") if not target_dir: + logger.info( + "Archive extract skipped without work dir", + extra={"batch_id": context.batch.pk, "batch_no": context.batch.batch_no}, + ) return SkillResult(success=True, data={"extracted_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 + logger.info( + "Archive extract started", + extra={ + "batch_id": context.batch.pk, + "attachment_id": binding.attachment_id, + "path": str(path), + }, + ) extracted_count += len(extract_archive(path, target_dir)) + logger.info( + "Archive extract finished", + extra={"batch_id": context.batch.pk, "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 index 3ce5cff..1ebdf5c 100644 --- a/review_agent/file_summary/skills/attachment_reader.py +++ b/review_agent/file_summary/skills/attachment_reader.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from collections.abc import Iterable from review_agent.models import FileAttachment @@ -8,6 +9,9 @@ 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" @@ -19,11 +23,28 @@ class AttachmentReaderSkill(BaseSkill): return self.run_for_attachments(attachments) def run_for_attachments(self, attachments: Iterable[FileAttachment]) -> SkillResult: - results = [read_attachment_details(attachment).to_dict() for attachment in attachments] + 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}, diff --git a/review_agent/file_summary/skills/document_page_count.py b/review_agent/file_summary/skills/document_page_count.py index f53ad77..5b4e4e4 100644 --- a/review_agent/file_summary/skills/document_page_count.py +++ b/review_agent/file_summary/skills/document_page_count.py @@ -1,25 +1,49 @@ 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": @@ -46,6 +70,15 @@ class DocumentPageCountSkill(BaseSkill): 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 @@ -61,4 +94,15 @@ class DocumentPageCountSkill(BaseSkill): "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 index 75a94dc..a705e9f 100644 --- a/review_agent/file_summary/skills/file_inventory.py +++ b/review_agent/file_summary/skills/file_inventory.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path from review_agent.models import FileSummaryBatchAttachment @@ -9,6 +10,9 @@ from ..services.inventory import scan_files_to_items from .base import BaseSkill, SkillResult, WorkflowContext +logger = logging.getLogger("review_agent.file_summary.skills.file_inventory") + + class FileInventorySkill(BaseSkill): name = "file_inventory" @@ -17,5 +21,17 @@ class FileInventorySkill(BaseSkill): resolve_storage_path(binding.attachment.storage_path) for binding in FileSummaryBatchAttachment.objects.filter(batch=context.batch) ] + 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 index cf86b63..188b84c 100644 --- a/review_agent/file_summary/skills/product_detect.py +++ b/review_agent/file_summary/skills/product_detect.py @@ -1,12 +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 index 9dde1e7..b49a614 100644 --- a/review_agent/file_summary/skills/registry.py +++ b/review_agent/file_summary/skills/registry.py @@ -1,8 +1,13 @@ 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] = {} @@ -11,6 +16,7 @@ class SkillRegistry: 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: @@ -19,4 +25,20 @@ class SkillRegistry: raise KeyError(f"Skill 未注册:{name}") from exc def execute(self, name: str, context: WorkflowContext) -> SkillResult: - return self.get(name).run(context) + 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 index 3e0c043..c70cdf9 100644 --- a/review_agent/file_summary/skills/summary_report.py +++ b/review_agent/file_summary/skills/summary_report.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging + from django.urls import reverse from review_agent.models import Message @@ -9,10 +11,14 @@ 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]) @@ -27,6 +33,14 @@ class SummaryReportSkill(BaseSkill): 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 index 7c2a0c7..413c768 100644 --- a/review_agent/file_summary/storage.py +++ b/review_agent/file_summary/storage.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path from uuid import uuid4 @@ -12,6 +13,9 @@ 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}" @@ -42,6 +46,16 @@ def save_uploaded_attachment(*, conversation: Conversation, user, uploaded_file) """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") @@ -63,7 +77,7 @@ def save_uploaded_attachment(*, conversation: Conversation, user, uploaded_file) is_active=True, ).update(is_active=False) - return FileAttachment.objects.create( + attachment = FileAttachment.objects.create( conversation=conversation, user=user, original_name=original_name, @@ -73,6 +87,16 @@ def save_uploaded_attachment(*, conversation: Conversation, user, uploaded_file) 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]: diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py index c32a688..a8a57b1 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -1,4 +1,5 @@ from django.contrib.auth.decorators import login_required +import logging from pathlib import Path from django.http import FileResponse, Http404, JsonResponse @@ -11,6 +12,9 @@ from .events import serialize_event 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: @@ -27,6 +31,15 @@ def attachments(request, conversation_id: int): 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, @@ -35,12 +48,23 @@ def attachments(request, conversation_id: int): ) 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]}) @@ -59,6 +83,10 @@ def attachment_detail(request, conversation_id: int, attachment_id: int): 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)}) @@ -120,12 +148,25 @@ def export_download(request, export_id: int): 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) content_type = ( "text/markdown; charset=utf-8" if exported.export_type == ExportedSummaryFile.ExportType.MARKDOWN else "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ) + 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, diff --git a/review_agent/file_summary/workflow.py b/review_agent/file_summary/workflow.py index 050ee88..8bfa147 100644 --- a/review_agent/file_summary/workflow.py +++ b/review_agent/file_summary/workflow.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from threading import Thread from uuid import uuid4 @@ -36,6 +37,9 @@ NODE_DEFINITIONS = [ ] +logger = logging.getLogger("review_agent.file_summary.workflow") + + def default_skill_registry() -> SkillRegistry: registry = SkillRegistry() registry.register(ArchiveExtractSkill()) @@ -65,6 +69,14 @@ def create_file_summary_batch( ) 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 = FileSummaryBatch.objects.create( conversation=conversation, @@ -82,6 +94,10 @@ def create_file_summary_batch( WorkflowNodeRun.objects.create(batch=batch, 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 @@ -91,6 +107,7 @@ class WorkflowExecutor: 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"]) @@ -100,6 +117,10 @@ class WorkflowExecutor: 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() @@ -111,8 +132,17 @@ class WorkflowExecutor: 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("Workflow run completed", extra={"batch_id": self.batch.pk}) 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 @@ -132,6 +162,15 @@ class WorkflowExecutor: if skill_name: 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}执行失败") node.status = WorkflowNodeRun.Status.SUCCESS @@ -144,11 +183,17 @@ class WorkflowExecutor: "node_progress", {"node_code": node.node_code, "status": node.status, "progress": node.progress}, ) + 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 index 8e1722e..cb53efe 100644 --- a/review_agent/file_summary/workflow_trigger.py +++ b/review_agent/file_summary/workflow_trigger.py @@ -14,11 +14,38 @@ ATTACHMENT_READER_KEYWORDS = ( "查看附件", "附件详情", "文件详情", + "文件内容", + "附件内容", + "简历文件", + "提供的文件", + "提供的简历", + "上传的文件", + "上传文件", + "这个文件", + "该文件", "总结附件", "总结文件", "分析这个文件", "阅读这个文件", ) +ATTACHMENT_REFERENCE_KEYWORDS = ("附件", "文件", "简历", "上传") +ATTACHMENT_READ_INTENT_KEYWORDS = ( + "阅读", + "读取", + "读", + "解析", + "分析", + "查看", + "提取", + "整理", + "总结", + "介绍", + "项目经历", + "工作经历", + "经历", + "信息", + "内容", +) @dataclass(frozen=True) @@ -45,7 +72,11 @@ def evaluate_file_summary_trigger(conversation: Conversation, content: str) -> T def evaluate_attachment_reader_trigger(conversation: Conversation, content: str) -> TriggerResult: text = (content or "").strip() - if not any(keyword in text for keyword in ATTACHMENT_READER_KEYWORDS): + 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( diff --git a/review_agent/llm.py b/review_agent/llm.py index 6680f84..6c7def7 100644 --- a/review_agent/llm.py +++ b/review_agent/llm.py @@ -53,6 +53,47 @@ def generate_reply(conversation, user_message: str) -> str: raise LLMRequestError("模型接口返回格式不符合预期。") from exc +def generate_completion(messages: list[dict[str, str]], *, temperature: float = 0.0) -> 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=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 stream_reply(conversation, user_message: str): """Streams incremental assistant text from the SiliconFlow chat endpoint.""" diff --git a/review_agent/services.py b/review_agent/services.py index f29880f..3d3f720 100644 --- a/review_agent/services.py +++ b/review_agent/services.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging from django.db.models import Q, QuerySet from django.conf import settings @@ -8,12 +9,12 @@ 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 .file_summary.workflow_trigger import ( - evaluate_attachment_reader_trigger, - evaluate_file_summary_trigger, -) from .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply from .models import Conversation, FileAttachment, Message +from .skill_router import route_message_intent + + +logger = logging.getLogger(__name__) def list_conversations(user, search: str = "") -> QuerySet[Conversation]: @@ -54,6 +55,14 @@ def append_user_message(conversation: Conversation, content: str) -> Message: 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) @@ -65,11 +74,20 @@ def append_user_message(conversation: Conversation, content: str) -> Message: def append_assistant_message(conversation: Conversation, content: str) -> Message: """Appends the deterministic assistant reply.""" - return Message.objects.create( + 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]: @@ -95,8 +113,18 @@ def stream_message(conversation: Conversation, content: str): user_message = append_user_message(conversation, content) assistant_parts: list[str] = [] - trigger = evaluate_file_summary_trigger(conversation, content) - attachment_reader_trigger = evaluate_attachment_reader_trigger(conversation, content) + 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", @@ -108,7 +136,7 @@ def stream_message(conversation: Conversation, content: str): }, ) - if trigger.reason == "missing_attachment": + 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}) @@ -122,7 +150,7 @@ def stream_message(conversation: Conversation, content: str): ) return - if attachment_reader_trigger.reason == "missing_attachment": + 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}) @@ -136,8 +164,16 @@ def stream_message(conversation: Conversation, content: str): ) return - if attachment_reader_trigger.should_start: + 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) @@ -152,7 +188,7 @@ def stream_message(conversation: Conversation, content: str): ) return - if trigger.should_start: + if route.starts_file_summary: batch = create_file_summary_batch( conversation=conversation, user=conversation.user, @@ -190,6 +226,18 @@ def stream_message(conversation: Conversation, content: str): except (LLMConfigurationError, LLMRequestError) as exc: fallback = f"模型调用失败:{exc}" assistant_parts = [fallback] + logger.warning( + "LLM stream failed", + extra={"conversation_id": conversation.pk, "error": str(exc)}, + ) + yield sse_event("error", {"message": fallback}) + except Exception as exc: + fallback = f"回复生成中断:{exc}" + assistant_parts.append("\n\n" + fallback) + logger.exception( + "Unexpected stream failure", + extra={"conversation_id": conversation.pk, "error": str(exc)}, + ) yield sse_event("error", {"message": fallback}) assistant_message = append_assistant_message(conversation, "".join(assistant_parts).strip()) @@ -230,6 +278,14 @@ def _select_attachments_for_reader(conversation: Conversation, content: str): 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 _format_attachment_reader_reply(attachments: list[dict[str, object]], message: str) -> str: if not attachments: return message or "当前对话没有可读取的附件。" diff --git a/review_agent/skill_router.py b/review_agent/skill_router.py new file mode 100644 index 0000000..d81ebbc --- /dev/null +++ b/review_agent/skill_router.py @@ -0,0 +1,189 @@ +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 .llm import LLMConfigurationError, LLMRequestError, generate_completion +from .models import Conversation, FileAttachment + + +logger = logging.getLogger(__name__) + +ROUTE_ACTIONS = {"normal_chat", "attachment_reader", "file_summary"} + + +@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 is_normal_chat(self) -> bool: + return self.action == "normal_chat" + + +def route_message_intent(conversation: Conversation, content: str) -> SkillRoute: + 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 _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="file_summary" if action == "file_summary" 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: + 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。" + "attachment_reader 用于用户要求阅读、提取、分析、总结、查看上传附件内容。" + "file_summary 用于用户要求自动汇总文件目录、页数、清单或生成目录页数报告。" + "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 diff --git a/tests/test_file_summary_skills.py b/tests/test_file_summary_skills.py index a700155..ba3daf1 100644 --- a/tests/test_file_summary_skills.py +++ b/tests/test_file_summary_skills.py @@ -1,4 +1,5 @@ import pytest +import logging from review_agent.file_summary.skills.base import BaseSkill, SkillResult, WorkflowContext from review_agent.file_summary.skills.registry import SkillRegistry @@ -25,3 +26,21 @@ def test_skill_registry_executes_registered_skill(django_user_model): 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_trigger.py b/tests/test_file_summary_trigger.py index 4d94164..ad0c8c3 100644 --- a/tests/test_file_summary_trigger.py +++ b/tests/test_file_summary_trigger.py @@ -1,6 +1,9 @@ import pytest -from review_agent.file_summary.workflow_trigger import evaluate_file_summary_trigger +from review_agent.file_summary.workflow_trigger import ( + evaluate_attachment_reader_trigger, + evaluate_file_summary_trigger, +) from review_agent.models import Conversation, FileAttachment @@ -30,3 +33,41 @@ def test_trigger_matches_keywords_only_when_active_attachment_exists(django_user 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_workflow.py b/tests/test_file_summary_workflow.py index 57534a5..b80e490 100644 --- a/tests/test_file_summary_workflow.py +++ b/tests/test_file_summary_workflow.py @@ -1,6 +1,7 @@ import pytest 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, @@ -102,6 +103,21 @@ def test_stream_message_uses_normal_llm_path_when_not_triggered(monkeypatch, dja assert "workflow_started" not in joined +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") @@ -124,3 +140,91 @@ def test_stream_message_reads_active_attachment_when_requested(settings, tmp_pat assert "detail.txt" in joined assert "RA-2026" in joined assert "workflow_started" not in joined + + +def test_stream_message_returns_error_event_when_unexpected_stream_error(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): + yield "已生成部分内容" + raise RuntimeError("provider connection reset") + + monkeypatch.setattr("review_agent.services.stream_reply", broken_stream_reply) + + frames = list(stream_message(conversation, "普通问题")) + + joined = "".join(frames) + assert "已生成部分内容" in joined + assert "回复生成中断" in joined + assert "done" in joined + assert Message.objects.filter(conversation=conversation, role=Message.Role.ASSISTANT).exists() + + +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() From 54c37edf196a65975c2de4140060ba317d21754a Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 17:56:54 +0800 Subject: [PATCH 022/111] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E8=A1=A5=E5=85=85=E4=B8=8E=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/css/login.css | 28 +++++++++ static/js/app.js | 88 +++++++++++++++++++++++++---- templates/home.html | 1 + tests/test_file_summary_frontend.py | 25 ++++++++ 4 files changed, 132 insertions(+), 10 deletions(-) diff --git a/static/css/login.css b/static/css/login.css index ea762c6..48a725a 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -581,18 +581,30 @@ input:focus { 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 { @@ -963,6 +975,7 @@ input:focus { border-collapse: collapse; font-size: 13px; line-height: 1.6; + table-layout: fixed; } .message-bubble th, @@ -971,6 +984,21 @@ input:focus { 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) { diff --git a/static/js/app.js b/static/js/app.js index a87c4b2..79d9cf6 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -18,6 +18,8 @@ var uploadStatus = document.getElementById("uploadStatus"); var workflowCardList = document.getElementById("workflowCardList"); var nodeAnchors = []; + var workflowPollingTimers = {}; + var WORKFLOW_POLL_INTERVAL_MS = 1500; if (!workspace) { return; @@ -236,10 +238,15 @@ } function renderAssistantContent(text) { - if (window.marked && window.DOMPurify) { - return window.DOMPurify.sanitize(window.marked.parse(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 || ""); } - return renderBasicMarkdown(text || ""); } function renderExistingAssistantMessages() { @@ -313,7 +320,7 @@ return; } var encodedTitle = title; - var existing = document.querySelector('.history-item[href*="conversation=' + conversationId + '"]'); + 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"); @@ -347,6 +354,7 @@ var item = document.createElement("a"); item.className = "history-item active"; + item.setAttribute("data-conversation-id", conversationId); item.href = "/?conversation=" + conversationId; item.innerHTML = '' + @@ -496,11 +504,20 @@ async function refreshWorkflowCard(batchId) { if (!summaryPanel || !batchId) { - return; + return ""; + } + var response; + try { + response = await fetch(templateUrl("data-status-url-template", "__batch_id__", batchId), { + cache: "no-store", + }); + } catch (error) { + console.error("Workflow status refresh failed", { batchId: batchId, error: error }); + return ""; } - var response = await fetch(templateUrl("data-status-url-template", "__batch_id__", batchId)); if (!response.ok) { - return; + console.error("Workflow status refresh returned non-OK", { batchId: batchId, status: response.status }); + return ""; } var payload = await response.json(); var card = ensureWorkflowCard({ @@ -508,7 +525,7 @@ batch_no: payload.batch.batch_no, }); if (!card) { - return; + return payload.batch.status || ""; } var status = card.querySelector(".workflow-status"); status.textContent = payload.batch.status; @@ -522,6 +539,50 @@ item.innerHTML = "" + escapeHtml(node.node_name) + "" + node.progress + "%"; list.appendChild(item); }); + return payload.batch.status || ""; + } + + function isWorkflowTerminalStatus(status) { + return status === "success" || status === "failed"; + } + + function stopWorkflowPolling(batchId) { + if (!workflowPollingTimers[batchId]) { + return; + } + window.clearInterval(workflowPollingTimers[batchId]); + delete workflowPollingTimers[batchId]; + } + + function startWorkflowPolling(batchId) { + if (!batchId || workflowPollingTimers[batchId]) { + return; + } + workflowPollingTimers[batchId] = window.setInterval(async function () { + var status = await refreshWorkflowCard(batchId); + if (isWorkflowTerminalStatus(status)) { + stopWorkflowPolling(batchId); + } + }, WORKFLOW_POLL_INTERVAL_MS); + refreshWorkflowCard(batchId).then(function (status) { + if (isWorkflowTerminalStatus(status)) { + stopWorkflowPolling(batchId); + } + }); + } + + function refreshRunningWorkflowCards() { + if (!workflowCardList) { + return; + } + workflowCardList.querySelectorAll(".workflow-card").forEach(function (card) { + var batchId = card.getAttribute("data-batch-id"); + var status = card.querySelector(".workflow-status"); + var statusText = status ? status.textContent.trim() : ""; + if (!isWorkflowTerminalStatus(statusText)) { + startWorkflowPolling(batchId); + } + }); } async function streamChat(event) { @@ -597,7 +658,13 @@ return; } - var payload = JSON.parse(dataText); + 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.conversation_id) { conversationIdInput.value = payload.conversation_id; @@ -616,7 +683,7 @@ assistantMessage.text.innerHTML = renderAssistantContent(assistantText); } else if (eventName === "workflow_started") { ensureWorkflowCard(payload); - refreshWorkflowCard(payload.batch_id); + startWorkflowPolling(payload.batch_id); } else if (eventName === "done") { if (payload.assistant_message_id) { assistantMessage.article.id = "message-" + payload.assistant_message_id; @@ -647,6 +714,7 @@ syncNodeRailVisibility(); bindNodeAnchorClicks(); renderExistingAssistantMessages(); + refreshRunningWorkflowCards(); if (chatScroll) { chatScroll.addEventListener("scroll", setActiveNode, { passive: true }); diff --git a/templates/home.html b/templates/home.html index e88a4d7..9c6d482 100644 --- a/templates/home.html +++ b/templates/home.html @@ -74,6 +74,7 @@ {% for conversation in conversations %} {{ conversation.title|default:"新对话" }} diff --git a/tests/test_file_summary_frontend.py b/tests/test_file_summary_frontend.py index a638aa8..4f46de1 100644 --- a/tests/test_file_summary_frontend.py +++ b/tests/test_file_summary_frontend.py @@ -24,6 +24,31 @@ def test_workspace_renders_summary_panel(client, django_user_model): assert 'id="summaryPanel"' in content assert 'id="uploadDropzone"' in content assert 'id="workflowCardList"' in content + assert 'data-conversation-id="' in content assert 'class="message-content markdown-content"' in content assert 'class="message-raw"' in content 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 From 460d418921400b3ebb6e34877c0658f0357b58ed Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 17:57:08 +0800 Subject: [PATCH 023/111] =?UTF-8?q?fix(file-summary):=20=E8=A1=A5=E5=BC=BA?= =?UTF-8?q?=20Office=20=E9=A1=B5=E6=95=B0=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file_summary/services/page_count.py | 245 ++++++++++++++++-- tests/test_file_summary_page_count.py | 85 ++++++ 2 files changed, 309 insertions(+), 21 deletions(-) diff --git a/review_agent/file_summary/services/page_count.py b/review_agent/file_summary/services/page_count.py index 3a90b9b..6b405a5 100644 --- a/review_agent/file_summary/services/page_count.py +++ b/review_agent/file_summary/services/page_count.py @@ -1,10 +1,14 @@ from __future__ import annotations +import logging from dataclasses import dataclass from pathlib import Path +from xml.etree import ElementTree +from zipfile import ZipFile SUPPORTED_EXTENSIONS = {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx"} +logger = logging.getLogger("review_agent.file_summary.page_count") @dataclass(frozen=True) @@ -26,34 +30,233 @@ def count_document_pages(path: str | Path) -> PageCountResult: return PageCountResult(status="success", page_count=len(PdfReader(str(file_path)).pages)) if ext == "docx": - from docx import Document - - properties = Document(str(file_path)).core_properties - pages = getattr(properties, "pages", None) + 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 pages: return PageCountResult(status="success", page_count=pages) return PageCountResult(status="uncertain") if ext == "xlsx": - from openpyxl import load_workbook - - workbook = load_workbook(str(file_path), read_only=True, data_only=True) - return PageCountResult(status="success", page_count=len(workbook.sheetnames)) + pages = _count_xlsx_sheets(file_path) or _count_excel_sheets_with_com(file_path) + if pages: + return PageCountResult(status="success", page_count=pages) + return PageCountResult(status="uncertain") if ext == "xls": - import xlrd - - workbook = xlrd.open_workbook(str(file_path), on_demand=True) - return PageCountResult(status="success", page_count=workbook.nsheets) + pages = _count_xls_sheets(file_path) or _count_excel_sheets_with_com(file_path) + if pages: + return PageCountResult(status="success", page_count=pages) + return PageCountResult(status="uncertain") if ext == "pptx": - from pptx import Presentation - - return PageCountResult(status="success", page_count=len(Presentation(str(file_path)).slides)) - if ext in {"doc", "ppt"}: - import olefile - - if olefile.isOleFile(str(file_path)): - return PageCountResult(status="uncertain") - return PageCountResult(status="failed", error_message="不是有效的 OLE 文件。") + pages = _count_pptx_slides(file_path) or _count_powerpoint_slides_with_com(file_path) + 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 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 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 _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/tests/test_file_summary_page_count.py b/tests/test_file_summary_page_count.py index e3c6077..3a7b4cd 100644 --- a/tests/test_file_summary_page_count.py +++ b/tests/test_file_summary_page_count.py @@ -1,4 +1,6 @@ import pytest +import shutil +from zipfile import ZipFile from docx import Document from openpyxl import Workbook from pptx import Presentation @@ -31,6 +33,89 @@ def test_count_document_pages_for_office_formats(tmp_path): 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._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._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._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_document_page_count_skill_marks_unsupported_and_success(tmp_path, django_user_model): xlsx_path = tmp_path / "a.xlsx" workbook = Workbook() From c78ff3a1fdf3271091dc5f6ded949f37694ca727 Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 19:44:42 +0800 Subject: [PATCH 024/111] =?UTF-8?q?fix(file-summary):=20=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E6=97=A0=E6=95=88=20Office=20=E6=96=87=E4=BB=B6=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=20COM=20=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file_summary/services/page_count.py | 34 +++++++++++++++---- tests/test_file_summary_page_count.py | 29 ++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/review_agent/file_summary/services/page_count.py b/review_agent/file_summary/services/page_count.py index 6b405a5..4f1e63a 100644 --- a/review_agent/file_summary/services/page_count.py +++ b/review_agent/file_summary/services/page_count.py @@ -4,7 +4,7 @@ import logging from dataclasses import dataclass from pathlib import Path from xml.etree import ElementTree -from zipfile import ZipFile +from zipfile import ZipFile, is_zipfile SUPPORTED_EXTENSIONS = {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx"} @@ -33,32 +33,38 @@ def count_document_pages(path: str | Path) -> PageCountResult: 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) + 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) + 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) + 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) + 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) + 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) + 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) @@ -143,6 +149,20 @@ def _ole_uncertain_or_failed(path: Path) -> PageCountResult: 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 diff --git a/tests/test_file_summary_page_count.py b/tests/test_file_summary_page_count.py index 3a7b4cd..1ce353e 100644 --- a/tests/test_file_summary_page_count.py +++ b/tests/test_file_summary_page_count.py @@ -77,6 +77,10 @@ def test_count_docx_pages_uses_word_com_fallback(monkeypatch, tmp_path): 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, @@ -91,6 +95,10 @@ def test_count_doc_pages_uses_word_com_fallback(monkeypatch, tmp_path): 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, @@ -105,6 +113,10 @@ def test_count_ppt_pages_uses_powerpoint_com_fallback(monkeypatch, tmp_path): 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, @@ -116,6 +128,23 @@ def test_count_excel_pages_uses_excel_com_fallback(monkeypatch, tmp_path): 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() From daa0642142373065012b8a83c315d5ecaf8cc3c2 Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 19:45:13 +0800 Subject: [PATCH 025/111] =?UTF-8?q?fix(agent):=20=E5=A2=9E=E5=BC=BA=20LLM?= =?UTF-8?q?=20=E6=B5=81=E5=BC=8F=E5=9B=9E=E5=A4=8D=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/llm.py | 10 ++++++++- review_agent/services.py | 38 ++++++++++++++++++++++++++++------ tests/test_llm_streaming.py | 41 +++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 tests/test_llm_streaming.py diff --git a/review_agent/llm.py b/review_agent/llm.py index 6c7def7..92e79c1 100644 --- a/review_agent/llm.py +++ b/review_agent/llm.py @@ -1,4 +1,5 @@ import json +import logging from urllib import error, request from django.conf import settings @@ -12,6 +13,9 @@ class LLMRequestError(RuntimeError): """Raised when the remote LLM provider call fails.""" +logger = logging.getLogger(__name__) + + def generate_reply(conversation, user_message: str) -> str: """Calls the SiliconFlow OpenAI-compatible chat endpoint and returns assistant text.""" @@ -130,7 +134,11 @@ def stream_reply(conversation, user_message: str): data = line[5:].strip() if data == "[DONE]": break - payload = json.loads(data) + 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", {}) diff --git a/review_agent/services.py b/review_agent/services.py index 3d3f720..376d3c5 100644 --- a/review_agent/services.py +++ b/review_agent/services.py @@ -219,26 +219,52 @@ def stream_message(conversation: Conversation, content: str): ) return + stream_failed = False + stream_error = "" try: for chunk in stream_reply(conversation, content): assistant_parts.append(chunk) yield sse_event("chunk", {"delta": chunk}) except (LLMConfigurationError, LLMRequestError) as exc: - fallback = f"模型调用失败:{exc}" - assistant_parts = [fallback] + stream_failed = True + stream_error = str(exc) logger.warning( "LLM stream failed", extra={"conversation_id": conversation.pk, "error": str(exc)}, ) - yield sse_event("error", {"message": fallback}) except Exception as exc: - fallback = f"回复生成中断:{exc}" - assistant_parts.append("\n\n" + fallback) + stream_failed = True + stream_error = str(exc) logger.exception( "Unexpected stream failure", extra={"conversation_id": conversation.pk, "error": str(exc)}, ) - yield sse_event("error", {"message": fallback}) + + if stream_failed: + try: + fallback_reply = generate_reply(conversation, content) + 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()) diff --git a/tests/test_llm_streaming.py b/tests/test_llm_streaming.py new file mode 100644 index 0000000..dae4f91 --- /dev/null +++ b/tests/test_llm_streaming.py @@ -0,0 +1,41 @@ +import io +from urllib import request + +import pytest + +from review_agent.llm import 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"] From 7e561ea21340f193cc8758c28d35deeb5ddae13a Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 19:45:49 +0800 Subject: [PATCH 026/111] =?UTF-8?q?fix(file-summary):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9=E5=8C=85=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E4=B8=8E=E7=BB=93=E6=9E=9C=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/file_summary/services/archive.py | 48 ++++++++ .../file_summary/skills/archive_extract.py | 35 +++++- .../file_summary/skills/file_inventory.py | 40 ++++++- review_agent/file_summary/views.py | 44 +++++++- review_agent/file_summary/workflow.py | 64 ++++++++--- review_agent/urls.py | 14 ++- static/css/login.css | 26 ++++- static/js/app.js | 106 +++++++++++++++++- templates/home.html | 10 +- tests/test_file_summary_frontend.py | 36 ++++++ tests/test_file_summary_views.py | 74 +++++++++++- tests/test_file_summary_workflow.py | 95 +++++++++++++++- 12 files changed, 560 insertions(+), 32 deletions(-) diff --git a/review_agent/file_summary/services/archive.py b/review_agent/file_summary/services/archive.py index 9e554e8..531336b 100644 --- a/review_agent/file_summary/services/archive.py +++ b/review_agent/file_summary/services/archive.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import subprocess from pathlib import Path from zipfile import ZipFile @@ -9,6 +10,8 @@ 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() @@ -63,6 +66,51 @@ def _extract_7z(archive_path: Path, target_dir: Path) -> list[Path]: 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, diff --git a/review_agent/file_summary/skills/archive_extract.py b/review_agent/file_summary/skills/archive_extract.py index 6e12f6f..bf2c71b 100644 --- a/review_agent/file_summary/skills/archive_extract.py +++ b/review_agent/file_summary/skills/archive_extract.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging from pathlib import Path +import re from review_agent.models import FileSummaryBatchAttachment @@ -13,34 +14,56 @@ 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 - target_dir = Path(context.batch.work_dir or "") - if not target_dir: - logger.info( - "Archive extract skipped without work dir", + 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=True, data={"extracted_count": 0}) + 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, "extracted_count": extracted_count}, + 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/file_inventory.py b/review_agent/file_summary/skills/file_inventory.py index a705e9f..0de852c 100644 --- a/review_agent/file_summary/skills/file_inventory.py +++ b/review_agent/file_summary/skills/file_inventory.py @@ -2,10 +2,12 @@ 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 @@ -13,14 +15,44 @@ 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 = [ - resolve_storage_path(binding.attachment.storage_path) - for binding in FileSummaryBatchAttachment.objects.filter(batch=context.batch) - ] + 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={ diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py index a8a57b1..fa27e52 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -5,7 +5,7 @@ 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 Conversation, ExportedSummaryFile, FileAttachment +from review_agent.models import Conversation, ExportedSummaryFile, FileAttachment, Message from review_agent.models import FileSummaryBatch, WorkflowEvent from .events import serialize_event @@ -90,6 +90,47 @@ def attachment_detail(request, conversation_id: int, attachment_id: int): return JsonResponse({"ok": True, "attachment": serialize_attachment(attachment)}) +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): @@ -107,6 +148,7 @@ def batch_status(request, batch_id: int): "success_files": batch.success_files, "failed_files": batch.failed_files, "total_pages": batch.total_pages, + "error_message": batch.error_message, }, "nodes": [ { diff --git a/review_agent/file_summary/workflow.py b/review_agent/file_summary/workflow.py index 8bfa147..5184ad9 100644 --- a/review_agent/file_summary/workflow.py +++ b/review_agent/file_summary/workflow.py @@ -1,9 +1,11 @@ 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 @@ -17,6 +19,7 @@ from review_agent.models import ( ) 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 @@ -54,6 +57,10 @@ 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( *, @@ -78,15 +85,29 @@ def create_file_summary_batch( }, ) + 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=build_batch_no(), + batch_no=batch_no, + work_dir=str(work_dir), ) for attachment in active_attachments: - FileSummaryBatchAttachment.objects.create(batch=batch, attachment=attachment) + 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"]) @@ -152,7 +173,7 @@ class WorkflowExecutor: record_event( self.batch, "node_progress", - {"node_code": node.node_code, "status": node.status, "progress": node.progress}, + {"node_code": node.node_code, "status": node.status, "progress": node.progress, "message": node.message}, ) skill_name = next( @@ -160,18 +181,35 @@ class WorkflowExecutor: "", ) if skill_name: - 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, + 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, - "skill_name": skill_name, - "result_message": result.message, + "status": node.status, + "progress": node.progress, + "message": node.message, }, ) - raise RuntimeError(result.message or f"{node.node_name}执行失败") + raise node.status = WorkflowNodeRun.Status.SUCCESS node.progress = 100 @@ -181,7 +219,7 @@ class WorkflowExecutor: record_event( self.batch, "node_progress", - {"node_code": node.node_code, "status": node.status, "progress": node.progress}, + {"node_code": node.node_code, "status": node.status, "progress": node.progress, "message": node.message}, ) logger.info( "Workflow node finished", diff --git a/review_agent/urls.py b/review_agent/urls.py index 737071d..418bb88 100644 --- a/review_agent/urls.py +++ b/review_agent/urls.py @@ -1,6 +1,13 @@ from django.urls import path -from .file_summary.views import attachment_detail, attachments, batch_events, batch_status, export_download +from .file_summary.views import ( + attachment_detail, + attachments, + batch_events, + batch_status, + conversation_messages, + export_download, +) urlpatterns = [ @@ -19,6 +26,11 @@ urlpatterns = [ attachment_detail, name="file_summary_attachment_detail", ), + path( + "api/review-agent/conversations//messages/", + conversation_messages, + name="review_agent_conversation_messages", + ), path( "api/review-agent/file-summary//status/", batch_status, diff --git a/static/css/login.css b/static/css/login.css index 48a725a..a177290 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -882,7 +882,9 @@ input:focus { .upload-dropzone span, .upload-status, .attachment-item span, -.workflow-card em { +.workflow-card em, +.workflow-card small, +.workflow-error { color: var(--muted); font-size: 12px; } @@ -949,6 +951,28 @@ input:focus { font-size: 13px; } +.node-status div { + display: grid; + min-width: 0; + gap: 2px; +} + +.node-status span, +.node-status small, +.workflow-error { + overflow-wrap: anywhere; + word-break: break-word; +} + +.workflow-error { + margin: 0; + padding: 8px 10px; + border-radius: 6px; + background: #fff1f0; + color: #b42318; + line-height: 1.5; +} + .status-running, .status-retrying { color: var(--accent); diff --git a/static/js/app.js b/static/js/app.js index 79d9cf6..ca9ae78 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -20,6 +20,7 @@ var nodeAnchors = []; var workflowPollingTimers = {}; var WORKFLOW_POLL_INTERVAL_MS = 1500; + var latestMessageId = 0; if (!workspace) { return; @@ -52,6 +53,15 @@ 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); } @@ -271,6 +281,9 @@ 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); } @@ -295,6 +308,48 @@ return { article: article, bubble: bubble, text: text }; } + function appendConversationMessage(message) { + if (!message || document.querySelector('.message[data-message-id="' + message.id + '"]')) { + return; + } + 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); + } + } + + 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(); + (payload.messages || []).forEach(appendConversationMessage); + if (payload.latest_message_id) { + latestMessageId = Math.max(latestMessageId, payload.latest_message_id); + } + syncNodeRailVisibility(); + bindNodeAnchorClicks(); + setActiveNode(); + scrollChatToBottom(); + } catch (error) { + console.error("Conversation message refresh failed", error); + } + } + function appendNode(targetId, title, isLatest) { if (!nodeRail) { return; @@ -530,13 +585,31 @@ 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 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.progress + "%"; + item.innerHTML = + '
    ' + + escapeHtml(node.node_name) + + "" + + (node.message ? "" + escapeHtml(node.message) + "" : "") + + "
    " + + node.progress + + "%"; list.appendChild(item); }); return payload.batch.status || ""; @@ -561,11 +634,13 @@ workflowPollingTimers[batchId] = window.setInterval(async function () { var status = await refreshWorkflowCard(batchId); if (isWorkflowTerminalStatus(status)) { + refreshConversationMessages(); stopWorkflowPolling(batchId); } }, WORKFLOW_POLL_INTERVAL_MS); refreshWorkflowCard(batchId).then(function (status) { if (isWorkflowTerminalStatus(status)) { + refreshConversationMessages(); stopWorkflowPolling(batchId); } }); @@ -666,6 +741,11 @@ 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); @@ -678,6 +758,10 @@ 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); @@ -687,6 +771,8 @@ } 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); @@ -711,7 +797,24 @@ } } + 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 })); + } + } + }); + } + syncNodeRailVisibility(); + syncLatestMessageIdFromDom(); bindNodeAnchorClicks(); renderExistingAssistantMessages(); refreshRunningWorkflowCards(); @@ -724,6 +827,7 @@ if (composer) { composer.addEventListener("submit", streamChat); } + bindPromptKeyboardShortcuts(); if (uploadDropzone && attachmentInput) { uploadDropzone.addEventListener("click", function () { diff --git a/templates/home.html b/templates/home.html index 9c6d482..be9f2e5 100644 --- a/templates/home.html +++ b/templates/home.html @@ -108,6 +108,7 @@
    @@ -174,6 +175,7 @@ class="summary-panel" id="summaryPanel" data-attachment-url-template="/api/review-agent/conversations/__conversation_id__/attachments/" + data-message-url-template="/api/review-agent/conversations/__conversation_id__/messages/" data-status-url-template="/api/review-agent/file-summary/__batch_id__/status/" data-events-url-template="/api/review-agent/file-summary/__batch_id__/events/" > @@ -220,10 +222,16 @@ {{ batch.batch_no }} {{ batch.status }} + {% if batch.error_message %} +

    {{ batch.error_message }}

    + {% endif %}
      {% for node in batch.node_runs.all %}
    1. - {{ node.node_name }} +
      + {{ node.node_name }} + {% if node.message %}{{ node.message }}{% endif %} +
      {{ node.progress }}%
    2. {% endfor %} diff --git a/tests/test_file_summary_frontend.py b/tests/test_file_summary_frontend.py index 4f46de1..20de8bf 100644 --- a/tests/test_file_summary_frontend.py +++ b/tests/test_file_summary_frontend.py @@ -25,6 +25,8 @@ def test_workspace_renders_summary_panel(client, django_user_model): 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 @@ -52,3 +54,37 @@ def test_frontend_updates_sidebar_conversation_by_stable_id(): 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_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 diff --git a/tests/test_file_summary_views.py b/tests/test_file_summary_views.py index 588d466..d88e872 100644 --- a/tests/test_file_summary_views.py +++ b/tests/test_file_summary_views.py @@ -2,7 +2,14 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse import pytest -from review_agent.models import Conversation, ExportedSummaryFile, FileAttachment, FileSummaryBatch +from review_agent.models import ( + Conversation, + ExportedSummaryFile, + FileAttachment, + FileSummaryBatch, + Message, + WorkflowNodeRun, +) pytestmark = pytest.mark.django_db @@ -99,3 +106,68 @@ def test_export_download_requires_batch_owner(client, tmp_path, django_user_mode assert "attachment" in allowed["Content-Disposition"] assert "summary.md" in allowed["Content-Disposition"] assert allowed["Content-Type"].startswith("text/markdown") + + +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"] == "未解出任何可扫描文件" diff --git a/tests/test_file_summary_workflow.py b/tests/test_file_summary_workflow.py index b80e490..fbe855d 100644 --- a/tests/test_file_summary_workflow.py +++ b/tests/test_file_summary_workflow.py @@ -1,5 +1,8 @@ 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 ( @@ -43,6 +46,7 @@ def test_create_batch_binds_active_attachments_and_initializes_nodes(django_user 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() @@ -67,6 +71,88 @@ def test_start_file_summary_workflow_runs_synchronously_for_tests(django_user_mo assert WorkflowEvent.objects.filter(batch=batch, event_type="workflow_completed").exists() +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") @@ -142,7 +228,7 @@ def test_stream_message_reads_active_attachment_when_requested(settings, tmp_pat assert "workflow_started" not in joined -def test_stream_message_returns_error_event_when_unexpected_stream_error(monkeypatch, django_user_model): +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="会话") @@ -151,14 +237,17 @@ def test_stream_message_returns_error_event_when_unexpected_stream_error(monkeyp 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: "非流式完整回复") frames = list(stream_message(conversation, "普通问题")) joined = "".join(frames) assert "已生成部分内容" in joined - assert "回复生成中断" in joined + assert "replace" in joined + assert "非流式完整回复" in joined assert "done" in joined - assert Message.objects.filter(conversation=conversation, role=Message.Role.ASSISTANT).exists() + 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( From 3c6ec67371d0111b98bf8f824d68e7d4bdc1d7cc Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 22:20:26 +0800 Subject: [PATCH 027/111] =?UTF-8?q?fix(file-summary):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E6=89=B9=E6=AC=A1=E8=BD=AE=E6=92=AD?= =?UTF-8?q?=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/css/login.css | 63 ++++++++++++++++ static/js/app.js | 107 ++++++++++++++++++++++++++++ templates/home.html | 26 ++++++- tests/test_file_summary_frontend.py | 61 +++++++++++++++- 4 files changed, 254 insertions(+), 3 deletions(-) diff --git a/static/css/login.css b/static/css/login.css index a177290..30cb7b3 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -941,6 +941,69 @@ input:focus { 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; diff --git a/static/js/app.js b/static/js/app.js index ca9ae78..cf4c6dd 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -554,9 +554,86 @@ 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); + } + async function refreshWorkflowCard(batchId) { if (!summaryPanel || !batchId) { return ""; @@ -612,9 +689,37 @@ "%"; 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 === "failed"; } @@ -817,6 +922,8 @@ syncLatestMessageIdFromDom(); bindNodeAnchorClicks(); renderExistingAssistantMessages(); + refreshWorkflowBatchCarousel(0); + bindWorkflowBatchCarouselControls(); refreshRunningWorkflowCards(); if (chatScroll) { diff --git a/templates/home.html b/templates/home.html index be9f2e5..90cca20 100644 --- a/templates/home.html +++ b/templates/home.html @@ -215,9 +215,14 @@

        工作流

        -
        + diff --git a/tests/test_file_summary_frontend.py b/tests/test_file_summary_frontend.py index 20de8bf..e60bc35 100644 --- a/tests/test_file_summary_frontend.py +++ b/tests/test_file_summary_frontend.py @@ -1,7 +1,7 @@ import pytest from django.urls import reverse -from review_agent.models import Conversation, Message +from review_agent.models import Conversation, FileSummaryBatch, Message, WorkflowNodeRun pytestmark = pytest.mark.django_db @@ -32,6 +32,53 @@ def test_workspace_renders_summary_panel(client, django_user_model): assert "自动汇总文件目录与页数" in content +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('home')}?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() @@ -88,3 +135,15 @@ def test_frontend_renders_workflow_error_messages(): assert "workflow-error" in script assert "node.message" in script assert ".workflow-error" in css + + +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 From 0fca20756b2c267794b514c47f88a7bcfd21d397 Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 22:45:14 +0800 Subject: [PATCH 028/111] =?UTF-8?q?feat(attachments):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/file_summary/views.py | 90 +++++++++++++++++++++++++++++- review_agent/urls.py | 12 ++++ tests/test_file_summary_views.py | 87 +++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 1 deletion(-) diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py index fa27e52..a87fee9 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -1,4 +1,6 @@ from django.contrib.auth.decorators import login_required +from django.db.models import Count, Q +import json import logging from pathlib import Path @@ -8,6 +10,7 @@ from django.views.decorators.http import require_http_methods from review_agent.models import Conversation, ExportedSummaryFile, FileAttachment, Message from review_agent.models import FileSummaryBatch, WorkflowEvent from .events import serialize_event +from .paths import resolve_storage_path from .storage import save_uploaded_attachment, serialize_attachment @@ -68,7 +71,7 @@ def attachments(request, conversation_id: int): return JsonResponse({"attachments": [serialize_attachment(item) for item in queryset]}) -@require_http_methods(["DELETE"]) +@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) @@ -80,6 +83,32 @@ def attachment_detail(request, conversation_id: int, attachment_id: int): 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"]) @@ -90,6 +119,65 @@ def attachment_detail(request, conversation_id: int, attachment_id: int): 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(["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, diff --git a/review_agent/urls.py b/review_agent/urls.py index 418bb88..6e480dd 100644 --- a/review_agent/urls.py +++ b/review_agent/urls.py @@ -1,16 +1,23 @@ from django.urls import path from .file_summary.views import ( + attachment_download, attachment_detail, attachments, batch_events, batch_status, + conversation_list, conversation_messages, export_download, ) urlpatterns = [ + path( + "api/review-agent/conversations/", + conversation_list, + name="review_agent_conversation_list", + ), path( "api/review-agent/conversations//attachments/", attachments, @@ -26,6 +33,11 @@ urlpatterns = [ 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, diff --git a/tests/test_file_summary_views.py b/tests/test_file_summary_views.py index d88e872..6aeaa7f 100644 --- a/tests/test_file_summary_views.py +++ b/tests/test_file_summary_views.py @@ -1,5 +1,6 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse +import json import pytest from review_agent.models import ( @@ -171,3 +172,89 @@ def test_batch_status_exposes_batch_and_node_errors(client, django_user_model): 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_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" From df3f393dd21c04a56fa60173cc14523114023c6e Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 22:45:48 +0800 Subject: [PATCH 029/111] =?UTF-8?q?feat(attachments):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/urls.py | 3 +- review_agent/views.py | 35 +++- static/css/login.css | 253 ++++++++++++++++++++++++++++ static/js/attachment_manager.js | 147 ++++++++++++++++ templates/attachment_manager.html | 139 +++++++++++++++ templates/home.html | 11 +- tests/test_file_summary_frontend.py | 78 ++++++++- 7 files changed, 660 insertions(+), 6 deletions(-) create mode 100644 static/js/attachment_manager.js create mode 100644 templates/attachment_manager.html diff --git a/config/urls.py b/config/urls.py index cd123c8..36df95c 100644 --- a/config/urls.py +++ b/config/urls.py @@ -2,10 +2,11 @@ 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 stream_chat, workspace +from review_agent.views import attachment_manager, stream_chat, workspace urlpatterns = [ path("", workspace, name="home"), + path("attachments/", attachment_manager, name="attachment_manager"), path("", include("review_agent.urls")), path("chat/stream/", stream_chat, name="chat_stream"), path( diff --git a/review_agent/views.py b/review_agent/views.py index a2aa67e..43dbded 100644 --- a/review_agent/views.py +++ b/review_agent/views.py @@ -1,4 +1,5 @@ from django.contrib.auth.decorators import login_required +from django.db.models import Count, Q from django.http import HttpRequest, HttpResponse, JsonResponse, StreamingHttpResponse from django.shortcuts import redirect, render from django.views.decorators.http import require_http_methods @@ -10,7 +11,7 @@ from .services import ( send_message, stream_message, ) -from .models import FileAttachment, FileSummaryBatch +from .models import Conversation, FileAttachment, FileSummaryBatch @login_required @@ -56,6 +57,38 @@ def workspace(request: HttpRequest) -> HttpResponse: ) +@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(["POST"]) def stream_chat(request: HttpRequest) -> HttpResponse: diff --git a/static/css/login.css b/static/css/login.css index 30cb7b3..212ead0 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -367,6 +367,8 @@ input:focus { } .tab { + display: inline-flex; + align-items: center; height: 60px; padding: 0 20px; border: 0; @@ -376,6 +378,7 @@ input:focus { font: inherit; font-weight: 600; border-bottom: 2px solid transparent; + text-decoration: none; } .tab.active { @@ -889,6 +892,23 @@ input:focus { 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; @@ -1177,6 +1197,215 @@ input:focus { } } +.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; +} + +.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; +} + @media (max-width: 640px) { .tabbar { overflow-x: auto; @@ -1244,6 +1473,30 @@ input:focus { 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; + } + + .attachment-search { + width: 100%; + } } @keyframes pulse-caret { 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/templates/attachment_manager.html b/templates/attachment_manager.html new file mode 100644 index 0000000..72e55dc --- /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/home.html b/templates/home.html index 90cca20..24196dc 100644 --- a/templates/home.html +++ b/templates/home.html @@ -9,10 +9,10 @@
        - + 首页 - - + 审核智能体 + 附件管理
        @@ -195,6 +195,11 @@

        附件

        +
        {% for attachment in attachments %} diff --git a/tests/test_file_summary_frontend.py b/tests/test_file_summary_frontend.py index e60bc35..87b3a88 100644 --- a/tests/test_file_summary_frontend.py +++ b/tests/test_file_summary_frontend.py @@ -1,7 +1,7 @@ import pytest from django.urls import reverse -from review_agent.models import Conversation, FileSummaryBatch, Message, WorkflowNodeRun +from review_agent.models import Conversation, FileAttachment, FileSummaryBatch, Message, WorkflowNodeRun pytestmark = pytest.mark.django_db @@ -32,6 +32,82 @@ def test_workspace_renders_summary_panel(client, django_user_model): 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('home')}?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("home") + 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="会话") From e58da668538ddbc067ecbacfa767cc5e74d0ef41 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 00:11:01 +0800 Subject: [PATCH 030/111] =?UTF-8?q?docs(regulatory-review):=20=E6=8B=86?= =?UTF-8?q?=E5=88=86=E6=B3=95=E8=A7=84=E6=A0=B8=E6=9F=A5=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...NMPA注册资料法规核查与整改闭环-第一批主链路.md | 415 ++++++++++++++++++ ...PA注册资料法规核查与整改闭环-第二批完整闭环.md | 242 ++++++++++ 2 files changed, 657 insertions(+) create mode 100644 docs/5.开发计划/2.NMPA注册资料法规核查与整改闭环-第一批主链路.md create mode 100644 docs/5.开发计划/2.NMPA注册资料法规核查与整改闭环-第二批完整闭环.md 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..8875b5e --- /dev/null +++ b/docs/5.开发计划/2.NMPA注册资料法规核查与整改闭环-第二批完整闭环.md @@ -0,0 +1,242 @@ +# NMPA 注册资料法规核查与整改闭环开发计划(第二批:完整闭环补齐) + +## 一、第二批目标 + +第二批在第一批主链路通过后执行,补齐完整整改闭环和交互能力: + +```text +适用条件对话选择框 +-> waiting_user 暂停恢复 +-> 整包复核 +-> 缺失项复核 +-> mock 通知留痕 +-> 更完整的过程产物 +-> 更强的前端交互和验收测试 +``` + +飞书真实 CLI/API、规则管理前端、自动填写目标文件不在第二批落地,进入 `docs/6.待办计划/第二阶段暂缓事项.md`。 + +--- + +## 二、阶段总览 + +| 阶段 | 名称 | 目标 | 验收 | +| --- | --- | --- | --- | +| RR2-1 | 适用条件确认 | 对话选择框确认产品类别、注册类型、临床评价路径等 | waiting_user 可暂停恢复 | +| RR2-2 | 核查能力增强 | 扩展章节、一致性、RAG 引用和文本抽取范围 | 复杂样例可识别更多问题 | +| 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 核查能力增强 + +### 任务 + +| 编号 | 内容 | 文件 | +| --- | --- | --- | +| RR2-2-001 | 扩展 YAML 规则中的必需章节和一致性字段 | `rules/nmpa_ivd_registration_v1.yaml` | +| RR2-2-002 | 增强文本抽取,缓存章节候选和字段候选 | `services/text_extract.py` | +| RR2-2-003 | 增强章节核查,支持别名、近似标题和证据片段 | `services/structure_check.py` | +| RR2-2-004 | 增强一致性核查,支持多个来源值和低置信度提示项 | `services/consistency_check.py` | +| RR2-2-005 | RAG 引用写入 `rag_result_json` 过程产物 | `services/rag_citation.py`、`storage.py` | +| RR2-2-006 | 增加测试 | `tests/test_regulatory_structure.py`、`tests/test_regulatory_consistency.py`、`tests/test_regulatory_rag.py` | + +### 验证命令 + +```bash +pytest tests/test_regulatory_structure.py tests/test_regulatory_consistency.py tests/test_regulatory_rag.py +``` + +### Codex 执行提示 + +```text +请增强章节核查、一致性核查和 RAG 过程产物。证据必须包含文件路径、命中片段、字段名或规则 ID,便于人工复核。 +``` + +--- + +## 五、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 暂停恢复、整包复核、缺失项复核、mock 通知留痕、增强章节/一致性核查和前端交互。 + +执行规则: +1. 从第一批完成后的稳定分支创建 codex/YYYYMMDD-NMPA法规核查完整闭环 分支。 +2. 按 RR2-1 到 RR2-6 顺序执行。 +3. 每阶段完成后运行对应验证命令。 +4. 不接真实飞书 CLI/API。 +5. 不做规则管理前端。 +6. 不做自动填写目标文件。 +7. 最后运行 python manage.py check 和 pytest 全量验收。 +``` From f179749cfbf03b88075b1228bdb34fe75fa30352 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 00:11:24 +0800 Subject: [PATCH 031/111] =?UTF-8?q?docs(todo):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E9=98=B6=E6=AE=B5=E6=9A=82=E7=BC=93=E4=BA=8B?= =?UTF-8?q?=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/6.待办计划/第二阶段暂缓事项.md | 51 +++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/6.待办计划/第二阶段暂缓事项.md diff --git a/docs/6.待办计划/第二阶段暂缓事项.md b/docs/6.待办计划/第二阶段暂缓事项.md new file mode 100644 index 0000000..c79c941 --- /dev/null +++ b/docs/6.待办计划/第二阶段暂缓事项.md @@ -0,0 +1,51 @@ +# 第二阶段暂缓事项待办表 + +## 一、待办原则 + +以下事项不进入第二阶段第一批或第二批落地范围。完成 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 | 产品关键信息抽取结果确认 | 原始需求 3 | P1 | 将第二阶段抽取字段转成可人工确认的信息表 | +| TODO-FILL-002 | 自动填写目标文件 | 原始需求 3 | P1 | 将确认后的字段写入注册申报表格或对照清单 | +| TODO-FILL-003 | 填写前后差异报告 | 自动填写风控 | P1 | 输出写入前后 diff,供人工复核 | +| TODO-FILL-004 | 自动填写审批确认 | 自动填写风控 | 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,后续可演进为独立服务 | From 665403735a5e26f2399c18168c3c94a380af6cf6 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 00:20:16 +0800 Subject: [PATCH 032/111] =?UTF-8?q?chore:=20=E7=A1=AE=E8=AE=A4=E6=B3=95?= =?UTF-8?q?=E8=A7=84=E6=A0=B8=E6=9F=A5=E5=9F=BA=E7=BA=BF=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From f52dcc197d1446c5cb09d292095f44bd9e2bb0e1 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 00:23:58 +0800 Subject: [PATCH 033/111] =?UTF-8?q?feat(regulatory):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=B3=95=E8=A7=84=E6=A0=B8=E6=9F=A5=E6=A8=A1=E5=9E=8B=E4=B8=8E?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=80=9A=E7=94=A8=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/file_summary/events.py | 9 +- .../file_summary/services/export_excel.py | 3 + review_agent/file_summary/services/report.py | 3 + review_agent/file_summary/views.py | 11 +- review_agent/file_summary/workflow.py | 9 +- ...latoryartifact_regulatoryissue_and_more.py | 479 ++++++++++++++++++ review_agent/models.py | 234 +++++++++ tests/test_regulatory_models.py | 137 +++++ 8 files changed, 878 insertions(+), 7 deletions(-) create mode 100644 review_agent/migrations/0003_regulatoryartifact_regulatoryissue_and_more.py create mode 100644 tests/test_regulatory_models.py diff --git a/review_agent/file_summary/events.py b/review_agent/file_summary/events.py index 3d9f80c..384f17c 100644 --- a/review_agent/file_summary/events.py +++ b/review_agent/file_summary/events.py @@ -4,7 +4,14 @@ 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, event_type=event_type, payload=payload or {}) + 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]: diff --git a/review_agent/file_summary/services/export_excel.py b/review_agent/file_summary/services/export_excel.py index b5b370d..b203cb3 100644 --- a/review_agent/file_summary/services/export_excel.py +++ b/review_agent/file_summary/services/export_excel.py @@ -54,6 +54,9 @@ def generate_excel_export(batch: FileSummaryBatch) -> ExportedSummaryFile: 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), diff --git a/review_agent/file_summary/services/report.py b/review_agent/file_summary/services/report.py index a1f9fc9..5543daa 100644 --- a/review_agent/file_summary/services/report.py +++ b/review_agent/file_summary/services/report.py @@ -65,6 +65,9 @@ def generate_markdown_report(batch: FileSummaryBatch) -> tuple[ExportedSummaryFi 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), diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py index a87fee9..8be64f3 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -283,11 +283,12 @@ def export_download(request, export_id: int): extra={"export_id": exported.pk, "storage_path": exported.storage_path}, ) return JsonResponse({"error": "文件不存在。"}, status=404) - content_type = ( - "text/markdown; charset=utf-8" - if exported.export_type == ExportedSummaryFile.ExportType.MARKDOWN - else "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - ) + 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", + } + content_type = content_types.get(exported.export_type, "application/octet-stream") logger.info( "Export download started", extra={ diff --git a/review_agent/file_summary/workflow.py b/review_agent/file_summary/workflow.py index 5184ad9..fe5378f 100644 --- a/review_agent/file_summary/workflow.py +++ b/review_agent/file_summary/workflow.py @@ -112,7 +112,14 @@ def create_file_summary_batch( attachment.save(update_fields=["upload_status"]) for code, name, _skill_name in NODE_DEFINITIONS: - WorkflowNodeRun.objects.create(batch=batch, node_code=code, node_name=name) + 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( 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/models.py b/review_agent/models.py index a5af82c..4a404e5 100644 --- a/review_agent/models.py +++ b/review_agent/models.py @@ -261,8 +261,13 @@ class WorkflowNodeRun(models.Model): 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) @@ -278,6 +283,10 @@ class WorkflowNodeRun(models.Model): ] 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", + ), ] @@ -287,8 +296,19 @@ class WorkflowEvent(models.Model): 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) @@ -299,6 +319,10 @@ class WorkflowEvent(models.Model): 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", + ), ] @@ -308,6 +332,7 @@ class ExportedSummaryFile(models.Model): class ExportType(models.TextChoices): MARKDOWN = "markdown", "Markdown" EXCEL = "excel", "Excel" + JSON = "json", "JSON" class Status(models.TextChoices): SUCCESS = "success", "成功" @@ -318,6 +343,9 @@ class ExportedSummaryFile(models.Model): on_delete=models.CASCADE, related_name="exports", ) + 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) @@ -331,4 +359,210 @@ class ExportedSummaryFile(models.Model): 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 RegulatoryReviewBatch(models.Model): + """Tracks one NMPA regulatory review workflow run.""" + + class Status(models.TextChoices): + PENDING = "pending", "待执行" + RUNNING = "running", "执行中" + 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) + 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", "已接受" + + 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"), ] 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 From 2a4dd6cfabb89bd0df407096ddc0d6e5665542ae Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 00:26:19 +0800 Subject: [PATCH 034/111] =?UTF-8?q?feat(regulatory):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=B3=95=E8=A7=84=E8=A7=84=E5=88=99=E7=89=88=E6=9C=AC=E6=A3=80?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 1 + review_agent/management/__init__.py | 1 + review_agent/management/commands/__init__.py | 1 + .../commands/regulatory_rules_check.py | 27 +++++ review_agent/regulatory_review/__init__.py | 1 + .../rules/nmpa_ivd_registration_v1.yaml | 58 ++++++++++ .../regulatory_review/services/__init__.py | 1 + .../regulatory_review/services/rule_loader.py | 106 ++++++++++++++++++ tests/test_regulatory_rule_loader.py | 68 +++++++++++ 9 files changed, 264 insertions(+) create mode 100644 review_agent/management/__init__.py create mode 100644 review_agent/management/commands/__init__.py create mode 100644 review_agent/management/commands/regulatory_rules_check.py create mode 100644 review_agent/regulatory_review/__init__.py create mode 100644 review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml create mode 100644 review_agent/regulatory_review/services/__init__.py create mode 100644 review_agent/regulatory_review/services/rule_loader.py create mode 100644 tests/test_regulatory_rule_loader.py diff --git a/requirements.txt b/requirements.txt index f257506..a04423d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ xlrd>=2.0 olefile>=0.47 py7zr>=0.21 playwright>=1.60 +PyYAML>=6.0 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/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/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/rules/nmpa_ivd_registration_v1.yaml b/review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml new file mode 100644 index 0000000..19cc16b --- /dev/null +++ b/review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml @@ -0,0 +1,58 @@ +code: nmpa_ivd_registration_v1 +name: NMPA IVD 注册资料 Demo 规则 +rag_collection: nmpa_ivd_registration_v1 +source_material_dir: docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告 +requirements: + - code: product_technical_requirements + title: 产品技术要求 + type: required + severity: blocking + category: completeness + file_keywords: + - 产品技术要求 + suggestion: 请补充产品技术要求并确认版本与注册申请资料一致。 + citation_query: 体外诊断试剂 产品技术要求 注册申报资料 + - code: instructions_for_use + title: 说明书 + type: required + severity: high + category: completeness + file_keywords: + - 说明书 + - 使用说明 + required_sections: + - 储存条件 + - 有效期 + - 样本要求 + suggestion: 请补充说明书并核对储存条件、有效期和样本要求章节。 + citation_query: 体外诊断试剂 说明书 储存条件 有效期 样本要求 + - code: registration_test_report + title: 注册检验报告 + type: required + severity: blocking + category: completeness + file_keywords: + - 注册检验报告 + - 检验报告 + suggestion: 请补充注册检验报告并复核报告覆盖的产品型号。 + citation_query: 体外诊断试剂 注册检验报告 注册申报资料 + - code: clinical_evaluation + title: 临床评价资料 + type: conditional + severity: high + category: completeness + file_keywords: + - 临床评价 + - 临床试验 + suggestion: 请根据适用情形补充临床评价资料或说明豁免依据。 + citation_query: 体外诊断试剂 临床评价资料 注册申报 + - code: essential_principles_checklist + title: 安全和性能基本原则清单 + type: recommended + severity: medium + category: completeness + file_keywords: + - 安全和性能基本原则 + - 基本原则清单 + suggestion: 建议补充安全和性能基本原则清单,便于审评追溯。 + citation_query: 体外诊断试剂 安全和性能基本原则清单 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/rule_loader.py b/review_agent/regulatory_review/services/rule_loader.py new file mode 100644 index 0000000..bbd671f --- /dev/null +++ b/review_agent/regulatory_review/services/rule_loader.py @@ -0,0 +1,106 @@ +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 列表。") + return payload + + +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/tests/test_regulatory_rule_loader.py b/tests/test_regulatory_rule_loader.py new file mode 100644 index 0000000..e74dc88 --- /dev/null +++ b/tests/test_regulatory_rule_loader.py @@ -0,0 +1,68 @@ +from pathlib import Path + +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_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 From 26490f7c46a9142d0dc9139ced26e805320df983 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 00:30:53 +0800 Subject: [PATCH 035/111] =?UTF-8?q?feat(regulatory):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E6=B3=95=E8=A7=84RAG=E7=B4=A2=E5=BC=95?= =?UTF-8?q?=E6=A3=80=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/settings.py | 17 ++ requirements.txt | 2 + .../commands/regulatory_rag_build.py | 33 ++++ .../services/rag_citation.py | 57 +++++++ .../services/rag_embedding.py | 82 ++++++++++ .../regulatory_review/services/rag_index.py | 148 ++++++++++++++++++ tests/test_regulatory_rag.py | 72 +++++++++ 7 files changed, 411 insertions(+) create mode 100644 review_agent/management/commands/regulatory_rag_build.py create mode 100644 review_agent/regulatory_review/services/rag_citation.py create mode 100644 review_agent/regulatory_review/services/rag_embedding.py create mode 100644 review_agent/regulatory_review/services/rag_index.py create mode 100644 tests/test_regulatory_rag.py diff --git a/config/settings.py b/config/settings.py index a2260fa..b8dfc9d 100644 --- a/config/settings.py +++ b/config/settings.py @@ -105,6 +105,23 @@ 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", +) +SILICONFLOW_BASE_URL = os.environ.get("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1") +SILICONFLOW_API_KEY = os.environ.get("SILICONFLOW_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")) + LOGGING = { "version": 1, "disable_existing_loggers": False, diff --git a/requirements.txt b/requirements.txt index a04423d..0c4aaa8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,5 @@ olefile>=0.47 py7zr>=0.21 playwright>=1.60 PyYAML>=6.0 +chromadb>=0.5 +httpx>=0.27 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..b8be556 --- /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) + 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/regulatory_review/services/rag_citation.py b/review_agent/regulatory_review/services/rag_citation.py new file mode 100644 index 0000000..8f54517 --- /dev/null +++ b/review_agent/regulatory_review/services/rag_citation.py @@ -0,0 +1,57 @@ +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, + } + ) + 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..bbaca66 --- /dev/null +++ b/review_agent/regulatory_review/services/rag_index.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import hashlib +import logging +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from django.conf import settings +from docx import Document +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") + + +@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 "\n".join(paragraph.text for paragraph in Document(str(path)).paragraphs) + 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_legacy_doc_with_libreoffice(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 collect_source_chunks(source_dir: Path) -> list[TextChunk]: + chunks: list[TextChunk] = [] + for path in sorted(source_dir.rglob("*")): + if not path.is_file(): + continue + try: + text = extract_text_from_path(path) + except RuntimeError as 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 build_chroma_index( + *, + source_dir: Path, + embedding_provider: EmbeddingFunction, + persist_path: Path | None = None, + collection_name: str | None = None, +) -> 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) + client = chromadb.PersistentClient(path=str(persist_path)) + 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) diff --git a/tests/test_regulatory_rag.py b/tests/test_regulatory_rag.py new file mode 100644 index 0000000..5ea6096 --- /dev/null +++ b/tests/test_regulatory_rag.py @@ -0,0 +1,72 @@ +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 + + +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]]) From 44d31d2a14cd82a709dcaa1800fd2c83622e7b75 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 00:34:12 +0800 Subject: [PATCH 036/111] =?UTF-8?q?feat(regulatory):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E6=B3=95=E8=A7=84=E6=A0=B8=E6=9F=A5=E8=A7=A6=E5=8F=91=E4=B8=8E?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/settings.py | 1 + review_agent/regulatory_review/events.py | 26 ++++ review_agent/regulatory_review/views.py | 42 ++++++ review_agent/regulatory_review/workflow.py | 151 ++++++++++++++++++++ review_agent/services.py | 51 +++++++ review_agent/skill_router.py | 34 ++++- review_agent/urls.py | 6 + tests/test_regulatory_views.py | 45 ++++++ tests/test_regulatory_workflow.py | 157 +++++++++++++++++++++ 9 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 review_agent/regulatory_review/events.py create mode 100644 review_agent/regulatory_review/views.py create mode 100644 review_agent/regulatory_review/workflow.py create mode 100644 tests/test_regulatory_views.py create mode 100644 tests/test_regulatory_workflow.py diff --git a/config/settings.py b/config/settings.py index b8dfc9d..ad63757 100644 --- a/config/settings.py +++ b/config/settings.py @@ -114,6 +114,7 @@ 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" SILICONFLOW_BASE_URL = os.environ.get("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1") SILICONFLOW_API_KEY = os.environ.get("SILICONFLOW_API_KEY", "") SILICONFLOW_EMBEDDING_MODEL = os.environ.get( 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/views.py b/review_agent/regulatory_review/views.py new file mode 100644 index 0000000..1842487 --- /dev/null +++ b/review_agent/regulatory_review/views.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +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 RegulatoryReviewBatch, WorkflowNodeRun + + +@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("批次不存在。") + nodes = WorkflowNodeRun.objects.filter( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + ).order_by("id") + return JsonResponse( + { + "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, + "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 + ], + } + ) diff --git a/review_agent/regulatory_review/workflow.py b/review_agent/regulatory_review/workflow.py new file mode 100644 index 0000000..fc0b2e6 --- /dev/null +++ b/review_agent/regulatory_review/workflow.py @@ -0,0 +1,151 @@ +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, + FileSummaryBatch, + Message, + RegulatoryReviewBatch, + WorkflowNodeRun, +) + +from .events import record_event + + +NODE_DEFINITIONS = [ + ("prepare", "准备", "prepare"), + ("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") + + +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), + ) + 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 + + def run(self) -> None: + 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(): + self._run_node(node) + 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)}) + 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}) + + 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: + 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}, + ) + + 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 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() diff --git a/review_agent/services.py b/review_agent/services.py index 376d3c5..9ac3729 100644 --- a/review_agent/services.py +++ b/review_agent/services.py @@ -11,6 +11,11 @@ from .file_summary.skills.attachment_reader import AttachmentReaderSkill from .file_summary.workflow import create_file_summary_batch, start_file_summary_workflow from .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply from .models import Conversation, FileAttachment, Message +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 @@ -219,6 +224,52 @@ def stream_message(conversation: Conversation, content: str): ) return + if route.starts_regulatory_review: + source_summary_batch = find_latest_successful_summary_batch(conversation) + if not source_summary_batch: + 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 + 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"已启动 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: diff --git a/review_agent/skill_router.py b/review_agent/skill_router.py index d81ebbc..05718e4 100644 --- a/review_agent/skill_router.py +++ b/review_agent/skill_router.py @@ -15,6 +15,7 @@ from .models import Conversation, FileAttachment logger = logging.getLogger(__name__) ROUTE_ACTIONS = {"normal_chat", "attachment_reader", "file_summary"} +ROUTE_ACTIONS.add("regulatory_review") @dataclass(frozen=True) @@ -34,6 +35,10 @@ class SkillRoute: 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 is_normal_chat(self) -> bool: return self.action == "normal_chat" @@ -100,7 +105,7 @@ def _route_with_llm( return SkillRoute( action=action, skill_name="attachment_reader" if action == "attachment_reader" else "", - workflow_type="file_summary" if action == "file_summary" else "", + workflow_type=action if action in {"file_summary", "regulatory_review"} else "", confidence=_float_or_zero(payload.get("confidence")), reason=str(payload.get("reason") or ""), source="llm", @@ -108,6 +113,15 @@ def _route_with_llm( def _route_with_rules(conversation: Conversation, content: str) -> SkillRoute: + 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( @@ -148,9 +162,10 @@ def _router_system_prompt() -> str: return ( "你是审核智能体的工具路由器,只判断是否需要调用工具,不直接回答用户。" "你必须只输出 JSON 对象,不要输出 Markdown。" - "可选 action:normal_chat、attachment_reader、file_summary。" + "可选 action:normal_chat、attachment_reader、file_summary、regulatory_review。" "attachment_reader 用于用户要求阅读、提取、分析、总结、查看上传附件内容。" "file_summary 用于用户要求自动汇总文件目录、页数、清单或生成目录页数报告。" + "regulatory_review 用于用户要求法规核查、NMPA核查、完整性核查、章节一致性核查、风险预警或整改建议。" "normal_chat 用于不需要读取附件或执行工作流的一般问答。" "输出字段:action、confidence、reason。" ) @@ -187,3 +202,18 @@ def _float_or_zero(value) -> float: 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) diff --git a/review_agent/urls.py b/review_agent/urls.py index 6e480dd..1a8c6e8 100644 --- a/review_agent/urls.py +++ b/review_agent/urls.py @@ -10,6 +10,7 @@ from .file_summary.views import ( conversation_messages, export_download, ) +from .regulatory_review.views import batch_status as regulatory_review_batch_status urlpatterns = [ @@ -58,4 +59,9 @@ urlpatterns = [ export_download, name="file_summary_export_download", ), + path( + "api/review-agent/regulatory-review//status/", + regulatory_review_batch_status, + name="regulatory_review_batch_status", + ), ] diff --git a/tests/test_regulatory_views.py b/tests/test_regulatory_views.py new file mode 100644 index 0000000..198f9a6 --- /dev/null +++ b/tests/test_regulatory_views.py @@ -0,0 +1,45 @@ +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" diff --git a/tests/test_regulatory_workflow.py b/tests/test_regulatory_workflow.py new file mode 100644 index 0000000..3d1b0ca --- /dev/null +++ b/tests/test_regulatory_workflow.py @@ -0,0 +1,157 @@ +import pytest + +from review_agent.models import ( + Conversation, + FileSummaryBatch, + Message, + RegulatoryReviewBatch, + WorkflowEvent, + WorkflowNodeRun, +) +from review_agent.regulatory_review.workflow import ( + NODE_DEFINITIONS, + 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, + ) + + 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_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 not RegulatoryReviewBatch.objects.exists() + + +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() From ec89e62661c99dc5738f9d737256eef122ca56da Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 00:36:18 +0800 Subject: [PATCH 037/111] =?UTF-8?q?feat(regulatory):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=B3=95=E8=A7=84=E6=A0=B8=E6=9F=A5=E5=9F=BA=E7=A1=80=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/regulatory_review/schemas.py | 18 ++++++++ .../services/completeness_check.py | 40 +++++++++++++++++ .../services/consistency_check.py | 41 +++++++++++++++++ .../services/structure_check.py | 41 +++++++++++++++++ .../services/text_extract.py | 31 +++++++++++++ review_agent/regulatory_review/storage.py | 35 +++++++++++++++ tests/test_regulatory_completeness.py | 44 +++++++++++++++++++ tests/test_regulatory_consistency.py | 14 ++++++ tests/test_regulatory_storage.py | 26 +++++++++++ tests/test_regulatory_structure.py | 13 ++++++ tests/test_regulatory_text_extract.py | 24 ++++++++++ 11 files changed, 327 insertions(+) create mode 100644 review_agent/regulatory_review/schemas.py create mode 100644 review_agent/regulatory_review/services/completeness_check.py create mode 100644 review_agent/regulatory_review/services/consistency_check.py create mode 100644 review_agent/regulatory_review/services/structure_check.py create mode 100644 review_agent/regulatory_review/services/text_extract.py create mode 100644 review_agent/regulatory_review/storage.py create mode 100644 tests/test_regulatory_completeness.py create mode 100644 tests/test_regulatory_consistency.py create mode 100644 tests/test_regulatory_storage.py create mode 100644 tests/test_regulatory_structure.py create mode 100644 tests/test_regulatory_text_extract.py 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/completeness_check.py b/review_agent/regulatory_review/services/completeness_check.py new file mode 100644 index 0000000..f1a684d --- /dev/null +++ b/review_agent/regulatory_review/services/completeness_check.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from review_agent.models import FileSummaryBatch +from review_agent.regulatory_review.schemas import Finding + + +def run_completeness_check(batch: FileSummaryBatch, rule_set: dict) -> list[Finding]: + items = list(batch.items.order_by("file_index")) + findings: list[Finding] = [] + for requirement in rule_set.get("requirements", []): + if requirement.get("type") not in {"required", "conditional", "recommended"}: + continue + matched = [ + item + for item in items + if _matches_item(item.file_name, item.relative_path, requirement.get("file_keywords", [])) + ] + if matched: + continue + findings.append( + Finding( + rule_code=requirement["code"], + category=requirement.get("category", "completeness"), + severity=requirement.get("severity", "medium"), + title=f"缺少{requirement['title']}", + detail=f"当前文件汇总批次未发现{requirement['title']}。", + suggestion=requirement.get("suggestion", ""), + evidence={ + "requirement_type": requirement.get("type"), + "matched_files": [], + "searched_keywords": requirement.get("file_keywords", []), + }, + ) + ) + return findings + + +def _matches_item(file_name: str, relative_path: str, keywords: list[str]) -> bool: + haystack = f"{file_name} {relative_path}".lower() + return any(str(keyword).lower() in haystack for keyword in keywords) 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..65782ed --- /dev/null +++ b/review_agent/regulatory_review/services/consistency_check.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import re +from collections import defaultdict + +from review_agent.regulatory_review.schemas import Finding + + +FIELDS = { + "产品名称": r"产品名称[::]\s*([^\n\r]+)", + "型号规格": r"型号规格[::]\s*([^\n\r]+)", + "预期用途": r"预期用途[::]\s*([^\n\r]+)", +} + + +def run_consistency_check(document_texts: dict[str, str]) -> list[Finding]: + findings: list[Finding] = [] + for label, pattern in FIELDS.items(): + 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: + continue + 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)}, + ) + ) + return findings + + +def _normalize(value: str) -> str: + return " ".join(value.strip().split()) 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..d12eac0 --- /dev/null +++ b/review_agent/regulatory_review/services/structure_check.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from review_agent.regulatory_review.schemas import Finding + + +def run_structure_check(document_texts: dict[str, str], rule_set: dict) -> list[Finding]: + findings: list[Finding] = [] + for requirement in rule_set.get("requirements", []): + required_sections = requirement.get("required_sections") or [] + if not required_sections: + continue + matching_docs = _matching_documents(document_texts, requirement.get("file_keywords", [])) + if not matching_docs: + continue + combined_text = "\n".join(matching_docs.values()) + for section in required_sections: + if section in combined_text: + 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)}, + ) + ) + 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 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..7d2d1cf --- /dev/null +++ b/review_agent/regulatory_review/services/text_extract.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import hashlib +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 = "" + + +SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx", ".pptx", ".xlsx", ".doc"} + + +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)) + 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) 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/tests/test_regulatory_completeness.py b/tests/test_regulatory_completeness.py new file mode 100644 index 0000000..3a0ce5c --- /dev/null +++ b/tests/test_regulatory_completeness.py @@ -0,0 +1,44 @@ +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 "缺少注册检验报告" 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" diff --git a/tests/test_regulatory_consistency.py b/tests/test_regulatory_consistency.py new file mode 100644 index 0000000..f2b2e97 --- /dev/null +++ b/tests/test_regulatory_consistency.py @@ -0,0 +1,14 @@ +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 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..b905b6a --- /dev/null +++ b/tests/test_regulatory_structure.py @@ -0,0 +1,13 @@ +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) diff --git a/tests/test_regulatory_text_extract.py b/tests/test_regulatory_text_extract.py new file mode 100644 index 0000000..713313f --- /dev/null +++ b/tests/test_regulatory_text_extract.py @@ -0,0 +1,24 @@ +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_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 == "" From 4c28466fe40348e1c12883289ab8b8ccd0a7e634 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 00:39:33 +0800 Subject: [PATCH 038/111] =?UTF-8?q?feat(regulatory):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=E5=BD=92=E5=B9=B6=E4=B8=8E=E6=A0=B8=E6=9F=A5?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../regulatory_review/services/export.py | 167 ++++++++++++++++++ .../regulatory_review/services/risk_assess.py | 50 ++++++ review_agent/regulatory_review/workflow.py | 57 ++++++ tests/test_regulatory_export.py | 49 +++++ tests/test_regulatory_risk_assess.py | 35 ++++ tests/test_regulatory_workflow.py | 43 +++++ 6 files changed, 401 insertions(+) create mode 100644 review_agent/regulatory_review/services/export.py create mode 100644 review_agent/regulatory_review/services/risk_assess.py create mode 100644 tests/test_regulatory_export.py create mode 100644 tests/test_regulatory_risk_assess.py diff --git a/review_agent/regulatory_review/services/export.py b/review_agent/regulatory_review/services/export.py new file mode 100644 index 0000000..b29a591 --- /dev/null +++ b/review_agent/regulatory_review/services/export.py @@ -0,0 +1,167 @@ +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}", + "", + "## 风险汇总", + "", + "| 风险等级 | 数量 |", + "| --- | --- |", + ] + 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 '-'} |" + ) + 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, + "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") + ], + } + + +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), + ] + ) + 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), + ) 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/workflow.py b/review_agent/regulatory_review/workflow.py index fc0b2e6..602da66 100644 --- a/review_agent/regulatory_review/workflow.py +++ b/review_agent/regulatory_review/workflow.py @@ -16,6 +16,13 @@ from review_agent.models import ( RegulatoryReviewBatch, WorkflowNodeRun, ) +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.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 @@ -89,6 +96,9 @@ def create_regulatory_review_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] = {} def run(self) -> None: self.batch.status = RegulatoryReviewBatch.Status.RUNNING @@ -131,6 +141,8 @@ class RegulatoryWorkflowExecutor: {"node_code": node.node_code, "status": node.status, "progress": node.progress, "message": node.message}, ) + self._execute_node(node.node_code) + node.status = WorkflowNodeRun.Status.SUCCESS node.progress = 100 node.finished_at = timezone.now() @@ -142,6 +154,51 @@ class RegulatoryWorkflowExecutor: {"node_code": node.node_code, "status": node.status, "progress": node.progress, "message": node.message}, ) + def _execute_node(self, node_code: str) -> None: + if node_code == "rule_scope": + self.rule_set = load_rule_file() + return + if node_code == "completeness_check": + self.findings.extend(run_completeness_check(self.batch.source_summary_batch, self._rules())) + return + if node_code == "text_extract": + self.document_texts = self._extract_source_texts() + return + if node_code == "structure_check": + self.findings.extend(run_structure_check(self.document_texts, self._rules())) + return + if node_code == "consistency_check": + self.findings.extend(run_consistency_check(self.document_texts)) + return + if node_code == "risk_assess": + persist_findings(self.batch, self.findings) + return + if node_code == "report_export": + exports = export_review_results(self.batch) + Message.objects.create( + conversation=self.batch.conversation, + role=Message.Role.ASSISTANT, + content=build_assistant_summary(self.batch, exports), + ) + + def _rules(self) -> dict: + if self.rule_set is None: + self.rule_set = load_rule_file() + return self.rule_set + + def _extract_source_texts(self) -> dict[str, str]: + texts = {} + for item in self.batch.source_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 start_regulatory_review_workflow(batch: RegulatoryReviewBatch, *, async_run: bool = True) -> None: executor = RegulatoryWorkflowExecutor(batch) 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_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_workflow.py b/tests/test_regulatory_workflow.py index 3d1b0ca..71a0114 100644 --- a/tests/test_regulatory_workflow.py +++ b/tests/test_regulatory_workflow.py @@ -2,8 +2,11 @@ import pytest from review_agent.models import ( Conversation, + ExportedSummaryFile, FileSummaryBatch, + FileSummaryItem, Message, + RegulatoryIssue, RegulatoryReviewBatch, WorkflowEvent, WorkflowNodeRun, @@ -155,3 +158,43 @@ def test_stream_message_starts_regulatory_workflow(monkeypatch, settings, django assert "workflow_started" in joined assert "\"workflow_type\": \"regulatory_review\"" in joined assert RegulatoryReviewBatch.objects.filter(conversation=conversation).exists() + + +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, + ) + + 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 conversation.messages.filter(role=Message.Role.ASSISTANT, content__contains="已完成 NMPA").exists() From bd805203f1290dd9a2e96a02e49bf95ad8a0d45d Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 00:43:18 +0800 Subject: [PATCH 039/111] =?UTF-8?q?feat(regulatory):=20=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E6=B3=95=E8=A7=84=E6=A0=B8=E6=9F=A5=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/file_summary/views.py | 1 + review_agent/regulatory_review/views.py | 16 +++++++ review_agent/views.py | 59 ++++++++++++++++++++++++- static/js/app.js | 59 +++++++++++++++++++------ templates/home.html | 15 ++++--- tests/test_regulatory_frontend.py | 58 ++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 21 deletions(-) create mode 100644 tests/test_regulatory_frontend.py diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py index 8be64f3..680d4a3 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -229,6 +229,7 @@ def batch_status(request, batch_id: int): { "batch": { "id": batch.pk, + "workflow_type": "file_summary", "batch_no": batch.batch_no, "status": batch.status, "product_name": batch.product_name, diff --git a/review_agent/regulatory_review/views.py b/review_agent/regulatory_review/views.py index 1842487..d51a249 100644 --- a/review_agent/regulatory_review/views.py +++ b/review_agent/regulatory_review/views.py @@ -26,6 +26,7 @@ def batch_status(request, batch_id: int): "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": [ @@ -40,3 +41,18 @@ def batch_status(request, batch_id: int): ], } ) + + +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) + ) diff --git a/review_agent/views.py b/review_agent/views.py index 43dbded..b85b86c 100644 --- a/review_agent/views.py +++ b/review_agent/views.py @@ -11,7 +11,7 @@ from .services import ( send_message, stream_message, ) -from .models import Conversation, FileAttachment, FileSummaryBatch +from .models import Conversation, FileAttachment, FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun @login_required @@ -42,6 +42,8 @@ def workspace(request: HttpRequest) -> HttpResponse: if current is None and conversations.exists(): current = conversations.first() + workflow_cards = build_workflow_cards(current) if current else [] + return render( request, "home.html", @@ -52,7 +54,7 @@ def workspace(request: HttpRequest) -> HttpResponse: "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 [], - "summary_batches": FileSummaryBatch.objects.filter(conversation=current).prefetch_related("node_runs").order_by("-created_at")[:5] if current else [], + "workflow_cards": workflow_cards, }, ) @@ -109,3 +111,56 @@ def stream_chat(request: HttpRequest) -> HttpResponse: 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: + 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 {}), + "created_at": batch.created_at, + "nodes": list( + WorkflowNodeRun.objects.filter( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + ).order_by("id") + ), + } + ) + return sorted(cards, key=lambda item: item["created_at"], reverse=True)[:5] + + +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) diff --git a/static/js/app.js b/static/js/app.js index cf4c6dd..f1d27bb 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -455,6 +455,12 @@ return summaryPanel.getAttribute(attributeName).replace(token, value); } + function statusUrlForWorkflow(workflow_type, batchId) { + var attributeName = + workflow_type === "regulatory_review" ? "data-regulatory-status-url-template" : "data-status-url-template"; + return templateUrl(attributeName, "__batch_id__", batchId); + } + function renderAttachments(attachments) { if (!attachmentList) { return; @@ -542,13 +548,17 @@ if (empty) { empty.remove(); } - var card = workflowCardList.querySelector('[data-batch-id="' + batch.batch_id + '"]'); + 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 || "文件汇总") + @@ -634,13 +644,13 @@ selectWorkflowBatchIndex(activeIndex); } - async function refreshWorkflowCard(batchId) { + async function refreshWorkflowCard(batchId, workflow_type) { if (!summaryPanel || !batchId) { return ""; } var response; try { - response = await fetch(templateUrl("data-status-url-template", "__batch_id__", batchId), { + response = await fetch(statusUrlForWorkflow(workflow_type || "file_summary", batchId), { cache: "no-store", }); } catch (error) { @@ -655,6 +665,7 @@ 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 || ""; @@ -673,6 +684,17 @@ } 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(); + } var list = card.querySelector("ol"); list.innerHTML = ""; (payload.nodes || []).forEach(function (node) { @@ -724,29 +746,37 @@ return status === "success" || status === "failed"; } - function stopWorkflowPolling(batchId) { - if (!workflowPollingTimers[batchId]) { + 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[batchId]); - delete workflowPollingTimers[batchId]; + window.clearInterval(workflowPollingTimers[key]); + delete workflowPollingTimers[key]; } function startWorkflowPolling(batchId) { - if (!batchId || workflowPollingTimers[batchId]) { + var card = workflowCardList ? workflowCardList.querySelector('[data-batch-id="' + batchId + '"]') : null; + var workflow_type = card ? card.getAttribute("data-workflow-type") || "file_summary" : "file_summary"; + var key = workflowTimerKey(batchId, workflow_type); + if (!batchId || workflowPollingTimers[key]) { return; } - workflowPollingTimers[batchId] = window.setInterval(async function () { - var status = await refreshWorkflowCard(batchId); + workflowPollingTimers[key] = window.setInterval(async function () { + var status = await refreshWorkflowCard(batchId, workflow_type); if (isWorkflowTerminalStatus(status)) { refreshConversationMessages(); - stopWorkflowPolling(batchId); + stopWorkflowPolling(batchId, workflow_type); } }, WORKFLOW_POLL_INTERVAL_MS); - refreshWorkflowCard(batchId).then(function (status) { + refreshWorkflowCard(batchId, workflow_type).then(function (status) { if (isWorkflowTerminalStatus(status)) { refreshConversationMessages(); - stopWorkflowPolling(batchId); + stopWorkflowPolling(batchId, workflow_type); } }); } @@ -757,10 +787,11 @@ } 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); + startWorkflowPolling(batchId, workflow_type); } }); } diff --git a/templates/home.html b/templates/home.html index 24196dc..55c425f 100644 --- a/templates/home.html +++ b/templates/home.html @@ -177,6 +177,7 @@ data-attachment-url-template="/api/review-agent/conversations/__conversation_id__/attachments/" data-message-url-template="/api/review-agent/conversations/__conversation_id__/messages/" data-status-url-template="/api/review-agent/file-summary/__batch_id__/status/" + data-regulatory-status-url-template="/api/review-agent/regulatory-review/__batch_id__/status/" data-events-url-template="/api/review-agent/file-summary/__batch_id__/events/" >
        @@ -221,10 +222,11 @@

        工作流

        + {% if batch.risk_label %} +

        {{ batch.risk_label }}

        + {% endif %} {% if batch.error_message %}

        {{ batch.error_message }}

        {% endif %}
          - {% for node in batch.node_runs.all %} + {% for node in batch.nodes %}
        1. {{ node.node_name }} @@ -250,11 +255,11 @@ {% empty %}
          暂无工作流
          {% endfor %} - {% if summary_batches %} + {% if workflow_cards %}
          - {% for batch in summary_batches %} + {% for batch in workflow_cards %} +

          + + {% endif %}
            {% for node in batch.nodes %}
          1. diff --git a/tests/test_regulatory_condition.py b/tests/test_regulatory_condition.py new file mode 100644 index 0000000..ccfc7a5 --- /dev/null +++ b/tests/test_regulatory_condition.py @@ -0,0 +1,139 @@ +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_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_frontend.py b/tests/test_regulatory_frontend.py index a89823e..f9a21d0 100644 --- a/tests/test_regulatory_frontend.py +++ b/tests/test_regulatory_frontend.py @@ -50,9 +50,63 @@ def test_workspace_renders_regulatory_workflow_card(client, django_user_model): 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('home')}?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 + + 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 diff --git a/tests/test_regulatory_workflow.py b/tests/test_regulatory_workflow.py index 71a0114..d175a04 100644 --- a/tests/test_regulatory_workflow.py +++ b/tests/test_regulatory_workflow.py @@ -102,6 +102,8 @@ def test_start_regulatory_review_workflow_runs_synchronously(django_user_model): 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) @@ -187,6 +189,8 @@ def test_workflow_generates_issues_exports_and_assistant_summary(settings, tmp_p 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) From 1bdc7322cf6abd08397961020149c2f5f798f270 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 09:27:42 +0800 Subject: [PATCH 041/111] =?UTF-8?q?feat(regulatory):=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E9=99=84=E4=BB=B64=E7=9B=AE=E5=BD=95=E6=A0=B8=E6=9F=A5?= =?UTF-8?q?=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rules/nmpa_ivd_registration_v1.yaml | 515 ++++++++++++++++-- .../services/completeness_check.py | 14 +- .../services/consistency_check.py | 4 + .../regulatory_review/services/rag_index.py | 7 + .../regulatory_review/services/rule_loader.py | 21 + .../services/structure_check.py | 31 +- .../services/text_extract.py | 49 +- review_agent/regulatory_review/workflow.py | 51 +- .../regulatory/attachment4_outline.json | 8 + tests/test_regulatory_completeness.py | 27 + tests/test_regulatory_consistency.py | 13 + tests/test_regulatory_rag.py | 16 + tests/test_regulatory_rule_loader.py | 25 + tests/test_regulatory_structure.py | 12 + tests/test_regulatory_workflow.py | 3 + 15 files changed, 753 insertions(+), 43 deletions(-) create mode 100644 tests/fixtures/regulatory/attachment4_outline.json diff --git a/review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml b/review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml index 19cc16b..909b63f 100644 --- a/review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml +++ b/review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml @@ -1,58 +1,503 @@ code: nmpa_ivd_registration_v1 -name: NMPA IVD 注册资料 Demo 规则 +name: NMPA IVD 注册资料附件 4 对齐规则 rag_collection: nmpa_ivd_registration_v1 -source_material_dir: docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告 +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: product_technical_requirements - title: 产品技术要求 + - 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: - - 产品技术要求 - suggestion: 请补充产品技术要求并确认版本与注册申请资料一致。 - citation_query: 体外诊断试剂 产品技术要求 注册申报资料 - - code: instructions_for_use - title: 说明书 + 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: - - 说明书 - - 使用说明 - required_sections: - - 储存条件 - - 有效期 - - 样本要求 - suggestion: 请补充说明书并核对储存条件、有效期和样本要求章节。 - citation_query: 体外诊断试剂 说明书 储存条件 有效期 样本要求 + 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: - - 注册检验报告 - - 检验报告 + file_keywords: [注册检验报告, 检验报告] suggestion: 请补充注册检验报告并复核报告覆盖的产品型号。 - citation_query: 体外诊断试剂 注册检验报告 注册申报资料 + 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: - - 临床评价 - - 临床试验 + file_keywords: [临床评价, 临床试验, 免临床, 同品种比对] suggestion: 请根据适用情形补充临床评价资料或说明豁免依据。 - citation_query: 体外诊断试剂 临床评价资料 注册申报 - - code: essential_principles_checklist - title: 安全和性能基本原则清单 - type: recommended + 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: 体外诊断试剂 安全和性能基本原则清单 + 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/services/completeness_check.py b/review_agent/regulatory_review/services/completeness_check.py index f1a684d..7b2b1ad 100644 --- a/review_agent/regulatory_review/services/completeness_check.py +++ b/review_agent/regulatory_review/services/completeness_check.py @@ -8,12 +8,17 @@ def run_completeness_check(batch: FileSummaryBatch, rule_set: dict) -> list[Find items = list(batch.items.order_by("file_index")) findings: list[Finding] = [] for requirement in rule_set.get("requirements", []): - if requirement.get("type") not in {"required", "conditional", "recommended"}: + 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, requirement.get("file_keywords", [])) + if _matches_item( + item.file_name, + item.relative_path, + item.directory_level, + [*requirement.get("file_keywords", []), *requirement.get("aliases", [])], + ) ] if matched: continue @@ -29,12 +34,13 @@ def run_completeness_check(batch: FileSummaryBatch, rule_set: dict) -> list[Find "requirement_type": requirement.get("type"), "matched_files": [], "searched_keywords": requirement.get("file_keywords", []), + "searched_fields": ["file_name", "relative_path", "directory_level"], }, ) ) return findings -def _matches_item(file_name: str, relative_path: str, keywords: list[str]) -> bool: - haystack = f"{file_name} {relative_path}".lower() +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) diff --git a/review_agent/regulatory_review/services/consistency_check.py b/review_agent/regulatory_review/services/consistency_check.py index 65782ed..1f24e17 100644 --- a/review_agent/regulatory_review/services/consistency_check.py +++ b/review_agent/regulatory_review/services/consistency_check.py @@ -10,6 +10,10 @@ 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]+)", } diff --git a/review_agent/regulatory_review/services/rag_index.py b/review_agent/regulatory_review/services/rag_index.py index bbaca66..b6a9d5a 100644 --- a/review_agent/regulatory_review/services/rag_index.py +++ b/review_agent/regulatory_review/services/rag_index.py @@ -107,12 +107,19 @@ def collect_source_chunks(source_dir: Path) -> list[TextChunk]: 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_attachment4(path: Path) -> bool: + normalized = path.name.replace(" ", "") + return "附件4" in normalized and "体外诊断试剂注册申报资料要求及说明" in normalized + + def build_chroma_index( *, source_dir: Path, diff --git a/review_agent/regulatory_review/services/rule_loader.py b/review_agent/regulatory_review/services/rule_loader.py index bbd671f..85855ad 100644 --- a/review_agent/regulatory_review/services/rule_loader.py +++ b/review_agent/regulatory_review/services/rule_loader.py @@ -47,9 +47,30 @@ def load_rule_file(path: str | Path | None = None) -> dict: 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, diff --git a/review_agent/regulatory_review/services/structure_check.py b/review_agent/regulatory_review/services/structure_check.py index d12eac0..d57758a 100644 --- a/review_agent/regulatory_review/services/structure_check.py +++ b/review_agent/regulatory_review/services/structure_check.py @@ -5,7 +5,27 @@ from review_agent.regulatory_review.schemas import Finding def run_structure_check(document_texts: dict[str, str], rule_set: dict) -> list[Finding]: findings: list[Finding] = [] + combined_all_text = "\n".join(document_texts.values()) for requirement in rule_set.get("requirements", []): + 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"申报资料目录缺少{requirement['title']}章节", + detail=f"未在申报资料目录或章节标题候选中发现{requirement['title']}。", + 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 not required_sections: continue @@ -14,7 +34,7 @@ def run_structure_check(document_texts: dict[str, str], rule_set: dict) -> list[ continue combined_text = "\n".join(matching_docs.values()) for section in required_sections: - if section in combined_text: + if _contains_any(combined_text, [section]): continue findings.append( Finding( @@ -39,3 +59,12 @@ def _matching_documents(document_texts: dict[str, str], keywords: list[str]) -> 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()) diff --git a/review_agent/regulatory_review/services/text_extract.py b/review_agent/regulatory_review/services/text_extract.py index 7d2d1cf..bd8dfab 100644 --- a/review_agent/regulatory_review/services/text_extract.py +++ b/review_agent/regulatory_review/services/text_extract.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import re from dataclasses import dataclass from pathlib import Path @@ -14,6 +15,9 @@ class ExtractedText: 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"} @@ -26,6 +30,47 @@ def extract_text(path: str | Path) -> ExtractedText: try: text = extract_text_from_path(file_path) except Exception as exc: - return ExtractedText(path=file_path, text="", status="failed", error_message=str(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) + 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 = {} + for label in ["产品名称", "型号规格", "预期用途", "管理类别", "分类编码", "注册类型", "临床评价路径"]: + match = re.search(rf"{label}[::]\s*([^\n\r]+)", text) + if match: + fields[label] = " ".join(match.group(1).strip().split()) + return fields diff --git a/review_agent/regulatory_review/workflow.py b/review_agent/regulatory_review/workflow.py index 264b04a..f89ff8f 100644 --- a/review_agent/regulatory_review/workflow.py +++ b/review_agent/regulatory_review/workflow.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging from pathlib import Path from threading import Thread @@ -26,6 +27,7 @@ from review_agent.regulatory_review.services.structure_check import run_structur from review_agent.regulatory_review.services.text_extract import extract_text from .events import record_event +from .storage import save_artifact NODE_DEFINITIONS = [ @@ -105,6 +107,7 @@ class RegulatoryWorkflowExecutor: self.rule_set: dict | None = None self.findings = [] self.document_texts: dict[str, str] = {} + self.text_extract_status: dict[str, dict[str, object]] = {} def run(self) -> None: self.batch.status = RegulatoryReviewBatch.Status.RUNNING @@ -176,6 +179,13 @@ class RegulatoryWorkflowExecutor: return if node_code == "text_extract": self.document_texts = self._extract_source_texts() + 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": self.findings.extend(run_structure_check(self.document_texts, self._rules())) @@ -184,7 +194,29 @@ class RegulatoryWorkflowExecutor: self.findings.extend(run_consistency_check(self.document_texts)) return if node_code == "risk_assess": - persist_findings(self.batch, self.findings) + issues = persist_findings(self.batch, 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 + ], + }, + ensure_ascii=False, + indent=2, + ), + metadata={"artifact": "rag_result_json"}, + ) return if node_code == "report_export": exports = export_review_results(self.batch) @@ -234,8 +266,25 @@ class RegulatoryWorkflowExecutor: if not path.is_absolute(): path = Path(settings.MEDIA_ROOT) / item.storage_path if not path.exists(): + self.text_extract_status[item.file_name] = { + "status": "missing", + "path": str(path), + "content_hash": "", + "section_candidates": [], + "field_candidates": {}, + "front_text": "", + } continue result = extract_text(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": result.field_candidates, + "front_text": result.front_text, + "error_message": result.error_message, + } if result.status == "success" and result.text: texts[item.file_name] = result.text return texts 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_regulatory_completeness.py b/tests/test_regulatory_completeness.py index 3a0ce5c..16467bb 100644 --- a/tests/test_regulatory_completeness.py +++ b/tests/test_regulatory_completeness.py @@ -42,3 +42,30 @@ def test_completeness_check_matches_existing_files_and_reports_missing(django_us 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.severity == "high" + assert missing_qms.evidence["searched_fields"] == ["file_name", "relative_path", "directory_level"] diff --git a/tests/test_regulatory_consistency.py b/tests/test_regulatory_consistency.py index f2b2e97..9f925e7 100644 --- a/tests/test_regulatory_consistency.py +++ b/tests/test_regulatory_consistency.py @@ -12,3 +12,16 @@ def test_consistency_check_reports_product_name_mismatch(): 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_rag.py b/tests/test_regulatory_rag.py index 5ea6096..356ffc6 100644 --- a/tests/test_regulatory_rag.py +++ b/tests/test_regulatory_rag.py @@ -6,6 +6,7 @@ from review_agent.regulatory_review.services.rag_citation import ( ) 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 def test_siliconflow_embedding_provider_posts_expected_payload(monkeypatch): @@ -70,3 +71,18 @@ def test_retrieve_citations_raises_when_index_missing(settings, tmp_path): 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) diff --git a/tests/test_regulatory_rule_loader.py b/tests/test_regulatory_rule_loader.py index e74dc88..b200b67 100644 --- a/tests/test_regulatory_rule_loader.py +++ b/tests/test_regulatory_rule_loader.py @@ -1,4 +1,5 @@ from pathlib import Path +import json import pytest from django.core.management import call_command @@ -27,6 +28,30 @@ def test_load_rule_file_reads_demo_requirements(): 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") diff --git a/tests/test_regulatory_structure.py b/tests/test_regulatory_structure.py index b905b6a..e883918 100644 --- a/tests/test_regulatory_structure.py +++ b/tests/test_regulatory_structure.py @@ -11,3 +11,15 @@ def test_structure_check_reports_missing_instruction_sections(): 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.evidence["expected_title"] == "临床评价资料" diff --git a/tests/test_regulatory_workflow.py b/tests/test_regulatory_workflow.py index d175a04..51eefeb 100644 --- a/tests/test_regulatory_workflow.py +++ b/tests/test_regulatory_workflow.py @@ -7,6 +7,7 @@ from review_agent.models import ( FileSummaryItem, Message, RegulatoryIssue, + RegulatoryArtifact, RegulatoryReviewBatch, WorkflowEvent, WorkflowNodeRun, @@ -201,4 +202,6 @@ def test_workflow_generates_issues_exports_and_assistant_summary(settings, tmp_p 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() From d88d642f6aac5412bdec5c8f2691e6fe67a2255f Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 09:32:39 +0800 Subject: [PATCH 042/111] =?UTF-8?q?feat(regulatory):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=95=B4=E6=94=B9=E5=A4=8D=E6=A0=B8=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0005_alter_regulatoryissue_status.py | 28 ++++ review_agent/models.py | 2 + .../regulatory_review/services/export.py | 37 ++++- .../services/rectification_review.py | 77 ++++++++++ review_agent/regulatory_review/views.py | 93 +++++++++++- review_agent/urls.py | 12 ++ tests/test_regulatory_rectification.py | 133 ++++++++++++++++++ 7 files changed, 375 insertions(+), 7 deletions(-) create mode 100644 review_agent/migrations/0005_alter_regulatoryissue_status.py create mode 100644 review_agent/regulatory_review/services/rectification_review.py create mode 100644 tests/test_regulatory_rectification.py 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/models.py b/review_agent/models.py index 7f70902..3cb703e 100644 --- a/review_agent/models.py +++ b/review_agent/models.py @@ -479,6 +479,8 @@ class RegulatoryIssue(models.Model): OPEN = "open", "待处理" RESOLVED = "resolved", "已整改" ACCEPTED = "accepted", "已接受" + REVIEW_PASSED = "review_passed", "复核通过" + REVIEW_FAILED = "review_failed", "复核未通过" batch = models.ForeignKey( RegulatoryReviewBatch, diff --git a/review_agent/regulatory_review/services/export.py b/review_agent/regulatory_review/services/export.py index b29a591..c9aba09 100644 --- a/review_agent/regulatory_review/services/export.py +++ b/review_agent/regulatory_review/services/export.py @@ -46,12 +46,19 @@ def build_markdown_report(batch: RegulatoryReviewBatch) -> str: "# 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)} |") @@ -60,6 +67,14 @@ def build_markdown_report(batch: RegulatoryReviewBatch) -> str: 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} |") return "\n".join(lines) @@ -67,6 +82,7 @@ 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": [ { @@ -82,6 +98,7 @@ def build_result_payload(batch: RegulatoryReviewBatch) -> dict[str, object]: } for issue in batch.issues.order_by("id") ], + "review_records": _review_records(batch), } @@ -165,3 +182,13 @@ def _create_excel_export(batch: RegulatoryReviewBatch, path: Path) -> ExportedSu 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 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/views.py b/review_agent/regulatory_review/views.py index fb29a1d..e4206a8 100644 --- a/review_agent/regulatory_review/views.py +++ b/review_agent/regulatory_review/views.py @@ -7,9 +7,10 @@ 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 RegulatoryReviewBatch, WorkflowNodeRun +from review_agent.models import FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun from review_agent.regulatory_review.events import record_event -from review_agent.regulatory_review.workflow import start_regulatory_review_workflow +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"]) @@ -78,6 +79,87 @@ def confirm_conditions(request, batch_id: int): progress=100, message="适用条件已确认", ) + + +@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}) record_event( batch, "condition_confirmed", @@ -126,3 +208,10 @@ def _normalize_conditions(conditions: dict) -> dict[str, str]: "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/urls.py b/review_agent/urls.py index a2be722..50f4c32 100644 --- a/review_agent/urls.py +++ b/review_agent/urls.py @@ -13,6 +13,8 @@ from .file_summary.views import ( 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, ) @@ -72,4 +74,14 @@ urlpatterns = [ 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", + ), ] 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 From d39e3fe2d5474a2fca13214b3957659c98f71d56 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 09:35:24 +0800 Subject: [PATCH 043/111] =?UTF-8?q?feat(regulatory):=20=E5=A2=9E=E5=8A=A0m?= =?UTF-8?q?ock=E9=80=9A=E7=9F=A5=E7=95=99=E7=97=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../regulatory_review/services/export.py | 33 +++++++- .../services/feishu_notifier.py | 39 +++++++++ review_agent/regulatory_review/workflow.py | 2 + tests/test_regulatory_notification.py | 79 +++++++++++++++++++ 4 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 review_agent/regulatory_review/services/feishu_notifier.py create mode 100644 tests/test_regulatory_notification.py diff --git a/review_agent/regulatory_review/services/export.py b/review_agent/regulatory_review/services/export.py index c9aba09..9a84eb8 100644 --- a/review_agent/regulatory_review/services/export.py +++ b/review_agent/regulatory_review/services/export.py @@ -75,6 +75,13 @@ def build_markdown_report(batch: RegulatoryReviewBatch) -> str: 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) @@ -99,6 +106,7 @@ def build_result_payload(batch: RegulatoryReviewBatch) -> dict[str, object]: for issue in batch.issues.order_by("id") ], "review_records": _review_records(batch), + "notifications": _notification_records(batch), } @@ -159,7 +167,7 @@ def _create_excel_export(batch: RegulatoryReviewBatch, path: Path) -> ExportedSu workbook = Workbook() sheet = workbook.active sheet.title = "法规问题清单" - sheet.append(["等级", "类别", "规则", "问题", "状态", "建议", "法规依据"]) + sheet.append(["等级", "类别", "规则", "问题", "状态", "建议", "法规依据", "通知记录"]) for issue in batch.issues.order_by("id"): sheet.append( [ @@ -170,6 +178,7 @@ def _create_excel_export(batch: RegulatoryReviewBatch, path: Path) -> ExportedSu issue.status, issue.suggestion, "; ".join(str(item.get("source", "")) for item in issue.citations), + _notification_summary_for_issue(batch, issue.pk), ] ) workbook.save(path) @@ -192,3 +201,25 @@ def _review_records(batch: RegulatoryReviewBatch) -> list[dict[str, object]]: 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/workflow.py b/review_agent/regulatory_review/workflow.py index f89ff8f..6a0e135 100644 --- a/review_agent/regulatory_review/workflow.py +++ b/review_agent/regulatory_review/workflow.py @@ -20,6 +20,7 @@ from review_agent.models import ( 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.risk_assess import persist_findings from review_agent.regulatory_review.services.rule_loader import load_rule_file @@ -195,6 +196,7 @@ class RegulatoryWorkflowExecutor: return if node_code == "risk_assess": issues = persist_findings(self.batch, self.findings) + create_mock_notifications(self.batch) save_artifact( self.batch, name="rag_result_json.json", diff --git a/tests/test_regulatory_notification.py b/tests/test_regulatory_notification.py new file mode 100644 index 0000000..e9c51f6 --- /dev/null +++ b/tests/test_regulatory_notification.py @@ -0,0 +1,79 @@ +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 + + +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" From 4e46f27c287018bc8f9a86193126fe2577dd3865 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 09:40:18 +0800 Subject: [PATCH 044/111] =?UTF-8?q?feat(regulatory):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E5=89=8D=E7=AB=AF=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/regulatory_review/views.py | 42 +++++++++++----------- review_agent/views.py | 2 ++ static/js/app.js | 23 ++++++++++++ templates/home.html | 17 +++++++++ tests/test_regulatory_frontend.py | 47 +++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 21 deletions(-) diff --git a/review_agent/regulatory_review/views.py b/review_agent/regulatory_review/views.py index e4206a8..86c8dcd 100644 --- a/review_agent/regulatory_review/views.py +++ b/review_agent/regulatory_review/views.py @@ -79,6 +79,27 @@ def confirm_conditions(request, batch_id: int): 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"]) @@ -160,27 +181,6 @@ def review_issues(request, batch_id: int): 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}) - 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, - } - } - ) def _format_risk_summary(risk_summary: dict) -> str: diff --git a/review_agent/views.py b/review_agent/views.py index a6be16f..429715e 100644 --- a/review_agent/views.py +++ b/review_agent/views.py @@ -140,6 +140,8 @@ def build_workflow_cards(conversation: Conversation) -> list[dict[str, object]]: "risk_label": _format_risk_label(batch.risk_summary or {}), "condition_json": batch.condition_json or {}, "condition_candidates": (batch.condition_json or {}).get("candidates") or {}, + "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( diff --git a/static/js/app.js b/static/js/app.js index b41d8a5..0717538 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -850,6 +850,28 @@ }); } + 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(); + }); + }); + } + async function streamChat(event) { event.preventDefault(); if (!composer || !promptInput || !sendButton || !chatStage) { @@ -1010,6 +1032,7 @@ refreshWorkflowBatchCarousel(0); bindWorkflowBatchCarouselControls(); bindConditionConfirmForms(); + bindRectificationActionButtons(); refreshRunningWorkflowCards(); if (chatScroll) { diff --git a/templates/home.html b/templates/home.html index 98a03e8..f8ee602 100644 --- a/templates/home.html +++ b/templates/home.html @@ -237,6 +237,23 @@ {% if batch.risk_label %}

            {{ batch.risk_label }}

            {% endif %} + {% if batch.workflow_type == "regulatory_review" %} +
            + + +
            +

            + 通知 {{ batch.notification_count|default:0 }} · 复核记录 {{ batch.review_record_count|default:0 }} +

            + {% endif %} {% if batch.error_message %}

            {{ batch.error_message }}

            {% endif %} diff --git a/tests/test_regulatory_frontend.py b/tests/test_regulatory_frontend.py index f9a21d0..1fac3e7 100644 --- a/tests/test_regulatory_frontend.py +++ b/tests/test_regulatory_frontend.py @@ -4,6 +4,8 @@ from django.urls import reverse from review_agent.models import ( Conversation, FileSummaryBatch, + RegulatoryArtifact, + RegulatoryNotificationRecord, RegulatoryReviewBatch, WorkflowNodeRun, ) @@ -102,6 +104,49 @@ def test_workspace_renders_condition_confirmation_form(client, django_user_model assert "甲胎蛋白检测试剂盒" 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('home')}?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() @@ -110,3 +155,5 @@ def test_frontend_selects_status_url_by_workflow_type(): assert "statusUrlForWorkflow" in script assert "bindConditionConfirmForms" in script assert "data-condition-confirm-form" in script + assert "bindRectificationActionButtons" in script + assert "data-rectification-action" in script From 48d94884b9baf0f87c9b357c0a064ced1aff69ba Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 10:31:39 +0800 Subject: [PATCH 045/111] =?UTF-8?q?docs(regulatory):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E6=89=B9=E9=99=84=E4=BB=B64=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../附件 4 体外诊断试剂注册申报资料要求及说明.doc | Bin 0 -> 58368 bytes ...PA注册资料法规核查与整改闭环-第二批完整闭环.md | 92 +++++++++++++++--- 2 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 docs/0.原始材料/附件 4 体外诊断试剂注册申报资料要求及说明.doc diff --git a/docs/0.原始材料/附件 4 体外诊断试剂注册申报资料要求及说明.doc b/docs/0.原始材料/附件 4 体外诊断试剂注册申报资料要求及说明.doc new file mode 100644 index 0000000000000000000000000000000000000000..769a99a15788a3847a33899448abdbf69603e868 GIT binary patch literal 58368 zcmeHw2Vhji+V*UEAPLoo0VNb6QkNDwgd$QmWfwwBNZF7Agn$WIN-zXiA(+JkrOO3D z5UvHectt=(kR~7kN)ZtO0YybAA_xfkKhK#=b`ywV`R?a;{~UO7&Y3gyop;`OXXc&R z{rjq%T>7toQ=-(>MYxLFc5mU~NcCP>h*5Z^+iknuPUkcLkLB0@Aqt#du|*6! z>Fp-O#fbM4*u;c^A}&U;o3`K<2Si{72@)m3oZSqXeSTIA39-VW|n9uT4l z?wxVBIr*!PtiJCe#4fn=D(ilt===4W(qHNO+n@%b5$_gJk8e^t3vx+Om1yef3_}y>vS2UC~*+mvvY1 zs(LJa|E=$({r^?(s=ms~cUkT#zrXihR(zDreSC!Ict6@1xZYh<#b0kXH}oLo*WaB2 zJ?CCEv=+X?80sIjJO8z$L;2k!&ZXJHTV<^<_nf&PY4VKjX}gVA^8J#1g>n;yl{ucN z^XGdOyV#0dJVgu9OazJpmZjOJLPy2R_ZH&hgeg;gundgz)xNhx$ZPqjdGf85XrA(Y zzE1zhqy^!7^Y4#P?ybd2!xod{y^Y8WH|0)Gx@wT&g0=JqMEDt)T89*Ciq&Guy*Y7v z{F~u6(~`7+!ZrB= z`D~@-V_%|rZ!N6zYo`QdRWFp^+i3YM@&u!J8rhbHc*thVc%UwxZSC zhVk_yUQ6l}=5K!A=$hNXcx8^`dpoUsvJ{?35vih7+w!j&q#IqFOUCz>rTln`HgFBp z%1b&W8*C=e)ST?Lsa+??Z_Y8$ucVFi>#K!sB~%Fc-nvA-f)OTY=Dt~^q%rj7MDmZP zplb__2Yj{XmQEbXZ>>b_td)k~h`>T}vGmirL_8HI6vRsRHiB{Y&#gDp;T9}<&MZu) zyTh%mR?5ZEYcd^f?MlRHwe%agZam2+)U(Qt0il`Zk&srG1(B4YRTS-DNl?ToSH{|8!>o6xb)!-A9uFyDN!zkL0LX_ zrrka@J<(a>SS^dAADzoK>|?(kZ3%B`xDmc4(`pg(CeQFoP9N9U)W=}8FiwB7lo-bf z`sXc-?qiTC9vkFscgT&ihSa=Hsphy!arZ~u2tQ$_4C7a;#of3)Z`+JcsXBc}!{iy; zaw?lEn>`|Y>}L(Tjr&G4GN|uXi)ZSNd@uWysD?AI8Fl*9L2FW*4-^Kv_R03P+f0-U zM+Ny_g}Ty6*`+*#lp7vr&7ezs(WJ4)25I8~M6?AV9nnT~Uc z_nKPiI7+A18mS$O8f>QQBz4#htrnHz=R=Pe2b<~Mb7rSha=4e>nSPR5psSv7c$s`m zHWS-SsBv|^te-ZB|5(9kqsj*|`)C`Jj zYPdCs{#7o$Q!>N1%~%+HH#$3qmMQv}DkUhsw_4aU`J3|+duXk;rv1|b#Ve9?{0w)- zkQsh+qL-a*_fT4XT)mk$!Fg5->y31g6ze&cb;;7}^rQoQ>GVr;d!%fEzdqUYNxHDl zn>yG(>eu*`6lW-g39vhSvlRNs5OYxEtwHOez3lqP4n}W#TKLb#Y|EPbj)tY#=L*lw z@i!}qE0-JLW5cbMTe-EP7^@?`!O`F6R5y$b_l)}4GCNjgwj+Ff{@Hx;3iSdpr`bS9 z-hJ$iQpjzJ;=dk0!&@!R?ev22tijj77A$kiHhO1l?zwl*Z=)2C7@4z}&<+Nbd-hgr zX_5yhZz-$DKa!uwX)2#(#r&TvGbM$i6?!Hvj6QAf7_$-5f^9z;d@kQ7nQuUj?l#^j zCma&t-lQtqptGDKrAU$AD^Y`ybD5%|{clOsvQjD?zO*!Liulk6JhWjI!PiiUDti0_G z4TE}*8XtWz$Ty0#VV;|sPrg^8R(%DSSg}ldVI&o z7UMXSRBD-wM{xytfump*FGEz&JIgUr%Fn>I!9HMd#G$l`dYg$9Ka{p@hSfryo-|^f z%Z|k(B75St84pBM)a&#mZv91-4UxcvMnkz^R_0le;zdnXxQO8egC`w z>e?6$SYkc#L+LD~>CjbEU#+T7HoqjvSANZpjZZlRL?`R!i-%i!t8yLcjS` zIO*LYOVW?>$4Ao>Ejkg|06U#QPodLpRCX9iH#wFC?NyvA?DZ^M{P){sQhr;nLW{ek{sQ!R!YQG zH0PlxuH--cuudJh`!IJ@GFv}$?{DVlWJk`FDU=fdn*5aVh4Uozq0>(@)*0cD89sJD z19ks>IpfVcwDv3gQ`_y6&D6c@997blX-nSb8|y)htN4X(A?BU4*c)*sWC+p){Sod&+Xdz{Izv9!m4J``DSQWKHJ#8PzcclEzxdi%Q zJRjt27|pM>3#Vtwuctz<8Qr2h#>ldbPD)3-hmGL!88`DcBvp#@v2zw;Km`3Hjaa@= z(Vlv-qAn@L`Jhj77Gc!bG5(Vrn~C#3O$5uK=9sqS>qB+=yUiuRRhx6-m|M*qsdT8( zYV~XIF{!b5Kf{$dkikP-;v^qX{;Rw+HB1U+P1nvEmce@UHxJ0;v!hRxZGdjc(X4ZR z*@*VLDqI}DUe6<3}+4OBWl{o=}ey-xH^t` zV@g&FW0oU0`s2uy{I$Q}IkXp+b0=noni{T0^VvPuW_mqy>fq+doDrl3FD0eyF`Y+v zq@zYt!5lM2y2!p-Qit#6xOEve6LUZ+Qw~%gcfdH!SP?UYmFH3pNv)URtrmV`i?^A6 zuvjfF`YBP~DV!@rK1|sY=?66taoWHq$0%NXll_|du5x0DWvRUjzkz1R5en0gaV{~Q z9K>8v=KFy&vL}{~;oyj=#Zo{Uo$LLhE)VfexqFJ#4^%HdTkBse8eODhq!i%jUZ-ad zDOjtl-rIsIUlCp128v8eTd}>%6i7YR%5lu z9Aj=j;#MwO?^6Z;jNqz;PCw?^#zjxj*=I3}b^tS}Y;$Z23&JrshaU4=L+T(0{k`np zPF@w!T=X@{dq4BpA)Gg2O4$?hBfadL4R0+NU%c~s+tem0^81vikUaNvojxS5 z*GQedYs^~wtund7WJ(Ch2{%W+5Yas%2zuT;^YQTR5syGFF#OJ0JLc{-9uFNHy*)2| zlA;dl*-;MxPR~}$0b}i~>6toxwdC3f8x6}R$vNU-8CJ_z`8#s<820AuHu@PHp{=N! zn-hKPQU=i7-@G{5ws3hg=|%pNyhSNX+Az)l`wy7wd@<~5ABnY>eaCpEa9(gq)FbIH z#iGWwzhg%pG)tY$%g#Akl?GdpT(eOcjT}`i$EZWi6;qRDOHi6L!?47@qLKN>$<$rs zxa=B6Ka?giTu`K&iMpu2nKNQm3qw(-xterk4#q&34`LiH`m<9UA$%dz9AOyZYLa&f zdpwqBQ~pxS<8ie^r>AUr-#9u+ayj!{xlkA=-4$hh4Wp*y8?ItS<-8Fis^Sql{hmdM zQX%Op#ZjMij6<2CpNZUNwW#nqJ;O^LWZvZrxiIV<&-_=^MrRuhJ7*n68L2H$hj=4= zLsIWl*{5_w54SQgID&2YBdlpzEiWg&i{7n@NzGoUl-O!nF3tjQwMy|7xtMc^R*S3c zYzot0%6+oQZK}jRCN&FlJ(}Yz6{pNSeNuW*JMpnso{RO&BI?NRvc&9NEU{d5kX)t1 zXhm`rc}?}&tg(`zMuEC?Pcd2P3cuv+0zW2tLW&Dnxd4y zIq?K$A?Taqfpdji{b5hnE7aEzHuo7r2SHg)ZVSrdy0}j7=tbmMK&O|QJ$NW%&xBD0 z?CH1`%h8XIoujj$NT!6D2jjzuR!a;@=VRXYYbN0!_5|Q%u@{qTt z&Woe8uv*H>tCUmEhf?-z$>(gAO_Lp?rXH|_#LSQ0kQ6@Y=!CRzSEL~QFkdeEK&%z| z3UBn)j&*3#uJpPfY4Jx_1yx(;*piad_l_0q=E>Ki>C0-NOrSsNw4~X#{H0iBMfztk5=w|yhV&%ZpRe#FA)CyZ|(7D{e6QzHZ8wp!3{6ub_- zot({4HB%<12Wa)~JR_p?5Sz)*AY}nH=J6Sm^8(-gk@Xd@lY zB(X)1Z}^pRRml_e9-#T`U{EoHm{ZLC^VC;4>!oHjsYl;53=9>9M(CLXq8b^7MMP=o zl3FVgj~E-qZ_8h(&46+a#ZS}Yj~I7jjqujxcQNlrZE`ejSVT*8!pFlWJ;^A8RS*&HHCo*m|!xI!)+J1rYv%PypozqBv@&A0Zo7iU=T1EpoyXf&=P0|Oat4^lO z;0dD;HT_d()Ho};H`4X!8z{p1dWZzvN2k)d>Y-BKJhgA5f^_op9bx5n`LVVP8>0YgbyL59-vkBsLLz+#B zXraF^_2OXr@9W#Xj__Cq_APaJTmTD~ys9AxjLjL_z22)|h3A07pr)XOy1l03v6kcU zLC0h3!yZEC?ka@#P{B1#BwWSY(Rs#WN)aHTzE08r=4SG9s!k{1D&O%`L?cm= zb$SK33b=WrodSV?9tZ&j1EYZ-Svqy)st4XneEDl%oRj$`u zkPF;nJ&*ZoYT!Q;%iZevg>F56z4sUaIsM;V|8A`BDZpZ2HLw}j51a(g6Yq0DW zXJ3Dx=ezMmvG#E0zrOxGSkGSqKLc*i>#GAnKnP$0Qh|xUOThcUm%!7`)tB@4dU4Lp zZ#|b)_gT~lhG5In?`Zp;vtM%c`ah>V3LnnB7Za1r2` zy)IA#WA+`u#;S7cKBgLWe*zrCcW@fVe~z)d3$}FD2i^fHU`!tdOaRmv{~Vq#Iaf}J zm($K)%6=&k=8wFT9d(H>Wk-FF-pY>Pul-U?wm-<_B7pVM4BKF>z&79<5P}`L1|Sxg z4=e{h0s;br2nPNQYy&<9tk{*R#}3>1z&pUV527yt>OKT_U_J0Du&g%p%fMgbfxnhA z|DVQI%n(iw8gJZP%88nsf4f_Kt2$<#O$3J4!8`zP1zX8|>IpIXVekZYs<`t%jI| z1G)eqz!jiE5cma%0EPpPHv+E!I~t?E0d51L7xc`>pi=;~dgB{#5cm}M47k`A<51x7 zeux7Y2V6RR=#u=m{nDW~@c1JB6}v6H^kTuKLzCz_iX2chzgIN3*AH=q^>v=#D?N88 z=k|(c$3)#yGijd1;T?Wl2YTsB$5VO^vR;XJR31k5_O2}F^fK}ETXT`l^M0l0Kudpf zo`)>`&AI%dv8vyvQO8fCes2RYsN)=<5Lg4KI&X=34*@;}&I7x#Q+*uxfV>b0K2ZEn zi2A>A7asTlHBx+)csW_@jEC}YcW3mE!tj`xzW@!nO^mxK6z?$k!kYS0+2J z?9<9#uFE<5=L(^+YTTD&Tet<*Wn9^77%x|Zr6m#95Y_ru$sO&^UszJ^y`fDBSIVpw z?%DI7B-c^7pDcT#^QxtD*ro=#FYm0O=iGsDw4R}@6RlvSEiqRdmE|zwRx7iN!?7M0 z6iG{Ty2(&BlL|v?*bByZSpVIK3>jAetBbUX2#RFQC|eolwLnK1Xz^6BXs;w=<{B|m zbkW~gB4sz=jWu7|8!`3q<~k!7j@AZpr??AjUnDm;+tbVSPFiU=T;)2xBgG3wS~k!M z*wgM@hthJ8-)M`>`l4+EtvjUsv5#g0XSFEXJ|DZX*xom*Qy6WvxrqqOsFs$V>kK;33 z;^^noh210e&HCr;bs4>34dP>`y*78)?>1r;9n|2nkDb=w-0|&WzhDf?YKR?4nbRcu5_z_eWv~M zNF`-$;RwwYY#CbFW6Kz>N4phUXfrg+=x=5#)%d@7PfAc^X@1|Av$KF!zBW_(xaCDV zRA~vq^3IFXN6`9;77mu!>IT(j$p5T0M;l?9l6MscF#k+jT7Qh6Pdd@!ft=5pC+%pT zrqh$oHWRJieeAS}<=VTnq>|PH>4}eGw+d|sHey|#`>}??!i3?K^#ElP)8Z<-gQFaK zaTwk;H+&j*wX@ww%We9k6{t>6`y!p5mfg}Kv`;qeRf6ZfIl^jTtL83vQk^zr42At$ zn$0}z_GDdMGtl09Ijp25N4V%&R+f{ylB^cmnKCAw{^lL_s5Z@}oL_0BN*gtAyR=88 zZKiDhs#Q43bUnJ{m)%AgGxNhQ4dYp#v;|+3!uq8h@@eeECmmI6rww*kW*jzLFdjrn zm8;?(#lPfkQdnB%sTyjYq|^4h%#L-~G13<;Ih=Vw?Fmq^)4GWE^%`HIe81kx9p$Bz zcWy&giwvox0^@UTC+E%byKI}R8_E#XSC#ZB<%+{+5Oz{a$?Is3lsPt&?DteGwA_(W zi1LqT36zvcj@+{~$Z!5;DX&WN%LOCl2d!q3qYsX_jy)xQ*jsRLgtU62-0@wbuL|PrkObJfi_yRW7%%@gQs+QX*0WbXiTVku1+s4xtTlmTQ(D|gZpIDMpMS2 zS`O`Hy-eKIEZvzWwtLlXm@`^6NvSREeP{-FtuRl5u3jjyAJ4Y^LRgT}JjGcZOGf zC^aY-N$Ym8Td>>jsc`OX;dkyhP|r3KcTub`8w;%#6}C^d?E6Uzxd(`KK&q;AonvQi zX|Yb7F?(XCQ0}^+WbiRDjRNeeuv*%sj+^yZ#+xH(>3(0_*qJ|L7l}W1yLLix@d&GLTzF)@8`d7T> z(3Bakl1Ns|dpQl`sV(UAcZ=uF6ltm3OeoF5S3AFky97HJnq=F+-?9|S-tB7A$t3O= zS#GFlS9>Jcr}${Q)|N(Z%cmZpELvInDrVIqDQ!#bovQ?EcJ_WY6MH|ktAZM{+#dl; zV4Q-()+L`9pX|XXS4nAF!+WPNB;_Yvbb4}N{Rp{xP4;0ev|d^2O$@=l$s4<2X>FN~YjDqlfr|2Vlyj5joMRnI|@)FD=d$=fry%$53wKu|M>Jy&wgg5M!W%|q=dw*&xGIugR zDj!Odshx6Vj{IIy8)a>?4^wCA;Ow3_U5xbUqS;ViO=9_cqt>NMSDn5?%9921dAITH z@R`%=j2NAyO2E35x{2ylISOM8e4jOcdZxeD-^%bRlq@$}Bg26)I_z8J*RuAYkj^&f*=tgdhb}kw!Tux{J@+lB z804u$>@%r-{yVuPMuGynKNu@>&6@GDbJzFXc2lzCj()%x$DKip`EGu>YuIXGyYV;w z1G|UiNRxV!Rof5ir`g0SUN1Wa2eo~Rv?-+%VQgPCy0}|ttA#mGx{Q~7M3LP-U1ZyE zjH*={f4!V?q~WMha<8*H^+oIhfflRNtDV0N-#&JZN7%FRtR}g4SoSM)m;2O%VU2Fn z#{a6kYNru-nEMNPDYd&KHH?zGF~}8;wn^#7mc?DYE72l2Pr&^EYzNeTIS0h=rFZMF=a+jM z6h#=~7!Qd&GDs-#vG?k?bIRd(Krc zG3MvA*{1V29biwCe3yGd)idi@?!L8|IC48}(2Y}ClZ&2bMXA_G&DpV>VlWurewo4Y?EDSSZqGuk|pih(DC zSS`*p@X>a^s!)_Qeui3s^!H%kb6wbn#BYxM(ww=b zyKDoqp>eXFSx?TVUb2TTy%X!tNe@RVQf|ok-{sm)9+|q2UCoZk8g{M;>BApqvw9}} z63tUaozI3r`=4y<1!7Y-SBJ=$9bO0r%0(3 z!<~);jXlEE*V4IGeCbS4&L(nx|BvKZ?y7O{hjXtjM+V$a$NnQJ?@{f%M9Cv^myxd+ z2|1|RhB|pv)vlD?YLsY<;~8ZbqZX}8@YEf)#T&7ECdeIv&hhxz3+7%;;@&K0SDyGO zDacbf|C;kXSr_bi9p|8O)Ixq@>tgAtZ_2r4uDo#l$7bpnZwz(l3+$O#BT9yIWW=vB zH2Y8$gSPiC#{;tf96itvPTGsKnM}E#=g6IlrT4}@A4;ElXHA~*eCX`hUIV#finBo> znA@fd@Q-rW)?;*f_HR7j(;xDZCtFC3g7T2(tLXIe%*Ige+8;Uk!>Ou|;+`sJ3R8;u z3ied27n|uy&T_N!cdG>>{g{r^_;Zp4!QiI2fmlX;5}mXkhf!T=Yl@o7-IQd@q|xtbIqNV z^u_sg<^p>ri&N*Qw&XdsOud6)arC=Mca~r7(tKlP?I?zkCsS@TJdd68)S$VdwJD$B zs5Nqgg8-hWJz+$q}Bkcl3Ox!^aeg}N!V?<2;Q@Wt4e zQpucC!tI>ZwCD_A(uwI%o?afp5rEag+L9VPV`h8d{(Y;3q3D8B%Tks`l#G=lRJH`0 ziQiTh@rL9=#?KsaWGL7Eq_2)6S2@MvV?SE(yn&@B$NZcxPb!l9@9?kF<0OlbHd8{{ zCl~=T9i5(KlU_p=K;%(88#E=op_eTojG)F4V=qmtki_$sN4~H zQ@$t8X<>ORvAV?Xb4a-_lNLr(4wj^e94$J>Th_VBsus9wmMO{kF{vlX^OesU(#IVS zCH3Vwv1|#H)m97B=PI2g_IRi+(K$7BCV+FUxTl(Wlb3x6_?LYKLog;%UdlROLu|}5 z25P)g)9w{|J$mQ}c}CpSf({1CG1-GCY3F6{hV>ZdwAH!JzJlirFfQk>Ov^DxXv7M2 zWvyK}`(dtaCOL{yy{kIk0;Aaydrg_zRh&Z2wPe}5uuT=M++v?Qcaf&%DakI~d0G-R zJG*ekysszm43-hbR~y)r)&=QH?r=fW-rf8 zRx84s4`mwiq)5sVx+o1@rV_W!S?AA(o-ng^NfCLn)X7BDx>b)(p7+IXMe(oO z9nW5xi?662SUoUMR6`316WzpP;!&-&#K71nPz=CZgyz;ugyLxce3;-~9dnR+aUY)$ zn(^~r`SsUQfHsvc0jq(Hz$V}zPy_Z_&46Ga28ac&4#1v318jst@eE{yW4{=12Dl7V zj=)x2pqCN*MuD{hh4>KI4}1(v8zjVBU_G!AaEXMSC{Pzj2W-Imz?Z-oVDVt&4LA?n z1_nh5kqo>5tOp*C5#quL{yT8N>ED(EZyb2zMc}}e1J7yyw&3Y0x*qry;+3|Obhe=^ zw$1$I?)$_`+kcj}BP|`b^B(fjVN2VNmJaJ|zge8GU*Er8rv0mEPP;tT^CsX1@DS>_ zFE9q!28Fk!C;a2xPL`(=A(JMNFRtJ?BvJl_T^MQ!&} z+=I|o7Xl?re9L|*QKEbFRwB$jzI+U76<=G5pYTxP4wC893Qz@Mu@UuFM! zrg(o-v2|~8%=~s&`(J<7(?*~y8T&rutmSd+3kTi-YCz`302#n-z-I{d`2e$l<-ixf zMIbd6_T#_{K&?2~6az6p22kGg|8q0|mlbn}V+UL@hty&ps_OGqJpT;*0aOKrJ)A1BJkH;NLi^;|m}x3HyYA#lU)? zXDZ|p&>QFv7=Q@C1Plfq2fjb?@#c>=pIE#6g#5R*=svsXuH2_j%GUm&NAgeISSv_~ zO0L5NF8PLi95tNJPwCOOR?&8kTE#m_$~-BibT94P!FC zYWoPbpp=f;SvM$i&q>i)G1zm0DqwrOTfKEhovFH8a}V|RIqL8t&^HnF0=xjM2fCn6 z9|u^!|AX!RG^*?2Bi%%`kv%a}dC`83V)uW?(4rHzVFXX6Jk{k@0ZG`Q^+1(96j()4>N_Rrw># zo;(JL5J{dB)-Uw;e0Yrz_pdR!)>sqn?o+tmnz3Y1gee}n=q9<@YOp%a<9N&=Ha{5b$z zZrYvC?L4sdk9%35@4w=b>B^Y&NR!~jbpMl=b3X26s>|8`z2=i)20%SJ(Qk zE$ZV(LtI557PRgY{d&7-aV6llYVM3LUAUk>aS`dovFSbBglFpf`GK(Lh2;mnRUKL# zKS)w-d8)4Z@-Y4!iEDk)qP{Mqfv!1!ZtBW{C{kC~&MD{aVtP_yDo<6RC0;d{1UBs%s2i}O0#d~iV6K)pEWj5aWK~_W z5+TQ%&!zQBou1SLYx*vt3R3FPw<=PqDmsb45S^&2>xA4_)1uIOxQMLz0h3`c1D!$E z8m!8EcX$bx_LVaNbuNz3EyeBTm+dWdEfE?k)VTB>o?2+|kVq8?A_HzdT59Acrf3kg zk;Cpf)C%mrWf-RDJywW@um{Hv*xa0r$4UBt z%&{Ik?X4Qh=+suN7HGu|#qVy&A7-kc6~B!a_*%Bg!AM6ZrsxJcrQ@mbXOM^#Ny036 zRObVthNJC7*Vk2BK2*fiN0S;VKtHr2)rM-I4M`S4$TyI`XLV+$nB7o<8{upp0v!B6 zQR}R}`~yeqBkSubS@*ld0+a#%PL0xE<4rbM)hbO|?w9FICDPz8cXH1ab-DmY%xLrV zNuRRmLe@t^@1@1M7I#PM@epjUlvI*~D~g&$G>0?+$B>sv=5Tc{wrSr* zYeT#q{$|R~XLc@|xcP$x_5F8!x_0Hjo7eh(Z0f!$&_Auo(N@Q; zpDE0!UAX+?8|_^7w6QeEy_M^?H!J+}8sE*B)$a7J9!Ji)u3Piq;Lbmsy*|JGmxce{ z*frSjYTq7SQ{UM9?7{P^uJ_vI-@V!H4Gn*4w!HC-rF)~^H(h^V&AHg5HV4|Q`Xcah zMO)RaYkX$J?Mn%Kpy^ldbzD5-{ISDPY403=X6-W*gJVB5Y_V65|EcS_YM*>KDS8qp zVz=YB!%EO=#o?7-Zw8%$(bCvA#APIvj=du;u(N7p^6 zr!1Y{n>1zDQ_ELA>=iw3^t2bVkG&c9PS}ALJMC>zb4BlU`Z-lA$6A}~Pg*(aiMS)R=P!QXWN`mae)5kv(l&l|!Kz*MH@50OTpYS(((6smKI)d< zc6R?^XUt1iH9u{9w{TU`%LgCY|K|GZhi{=McNTQS___X5QNX7G*k_0_sYxS~nmhd+ zkdU5`l9WLX@%eB-g{^^=Lry)pbZp_#wux^CJ!)RNXZ71xMUO=R-FCk4@seXl&Yqsy z_M;Qk4?W+{H>$}ZPv57L_BP9V>*B;FD{HR$taq*LP0~*7yzi@T#?{#M@0rz3^!Xq& z{Ki?v-Z}rV$a9}ud*sVUziZr}f=j!*XTI5Mg?hfs3G~sdR2e2`IW9-?{>WX>@WY?Rx|DBN9&Fb=zf0rnI{fE6#d+T z!)(d#)U4+6@c1Ux`+oB5bdSDqH^M&isG7R;o7a!Fet1BgNlVA}TGuJD;O$+{z8|r6 z+M@yg$o~4ncO$=kX8Dizr$2VE{WkB0P14tI&vZYsvP-1_;uYi^zL{QAILuP3Yzt|_?gKYabt%Fn$vadD;i8kaKDrX;QpHutf*EoyN3 z7k#(KPPSckJz{CtuE+AWt*-W>*XOYh%=|!f+&Agg_h-jeI{y7r*ZiJ(#JuI~=G+FI zzo}WXL4}T;g8I5Gxs==BqdLoc?{5&W_Qfunn#N48+w5fbtxZ>TZTsGnT?*EH)ytH& z=Fs_#-;PRe+qT9-vkxxXHLmOP2Y;OS`o|ys(pcR0-Fm~D#&1k5CsoMxa2-5s_oLUo zdF$KP)^*)E{Dmv~zkGYh$a(o5u|LMep8ovG*?pTEhkbh@dh6J?bv+N@`RQv%E-sF2 z>b`h#+`BHa&BY4UBdXSZb!o+%6}6fSdcIpP+Yf_JWu5qLP`}Kt`&Bpf z7&Ii~=_>i#UwS-$f77{Rbb*trdsppf4&G9)b#6<)$`9Z4e!{Y=o%Q|aUzq+-@n(wH_y2D|M|1RGp8?h>)v45sT`l!A-H*b9X-lSok z@AsWoXWXTqPIq0>CFjz`ALn)Lv}W`3-k%*g^<>qAA7XYq_E8)EE$^GW`hPX9>-diO zIeB^M2W&fQZSVb&`O=9hODAvY)T4UT=%#7CF7|S%n&=w4=!=$K*WMr6C~y1e75bLB z*|r1z{ay`UU;pcm-~W8cj>Ma1cA0h#>2Yp<@Yq~)z4!j{joZKHd>Z3XCpa~BMUa20 zEwM?h@iQNcPm4M}pu27T53{#6?)xodNz0xnR`PF}4I1n|oZnbWHX?y)b! zcIIrW^g_>BnRC2rPAph3Hm}q4PA`m0+&HRo+VY!2+pPE7{j%|K|Galv4rsFBoxBC% z@4frLgLPhA?qNN#|Hr&-4WGXG^s6`Ruf008aMRCU%}N@WGI0O%-v9a{ykp~*o4ns@ z^o6e9v@_>NjIBA~^eZ*e=l$eyHFo2PesA4;Z{80BuKoD-{pVNonmTdMYvzpuBWqqu zJ-zA4oH7UAcJKLV z#RG0vuRR*s;ej7FeAi-Z1KZYhalX+{_UrVIUfF5$yR7!lzkHzeeU+ZsmhsNXl+zbi z)rlChV&g=&J)bvTm-y4FcLD=Po|tA0UeIS*TAlDAKDz_|>6z)Z@G0x#M>{5jzqMvn zpKJN1*Zw7jM-7Vq?#$+CubjH^(EQvgoxhMsnbXeJAgp6d`uHjj zZr$_7pk?6E^^s(FL&$dW~kM7*|f&JuZ<)Q_PJQ_7 zD^p)x)Fz{FOsievTEFkQbmfqmvt}7WyPYz=wkCGqS1UdH^$UJ#%(eGV&3Yqr<*qhv z4}TSz-!iwy`=`DSZ87t!!p+}(*#CpMKfU$w{IAx&@?NN^&Fp9Db?`p$&Wq>Fi{GC! zI|WD2_`80ewkg?bmH)Hj>wR8(kH;&Xht9neb)73TcD~x$W;)%zFH^qRIBI|8*2DMg z@)`c@rHwxQ_AV}1_-^9AHdkBqZlCA&o8DVH`t7Q3z53YTl~cDEvvSr%zxH^IfTmaH zH2V14twZxWRv`Bq9z^lVIbDmhZW#szv&xAzy8)h8sICM??lxIe@{r?!lmbR6SvoY*IYUKka%Ej z*h2dox7r2FNNHZ%f5`3dZkb1)at~UzvrwpTcJ}zhs3mu0STi65kY!p zhlI{`Q^$-M-JyB&%tUCJ37O4C;g}$NOCFUmGGPo}Gg6ul%}5xFh)1S1Z>iS@Hy@cW zEWK`^Ii*GCx@lut)D0Xn4o^cz;K`iQl5Q>OhNqz;@RX34h%pU(6g?;xe3vf8ep)G) zR?1In<zb1WLqP-N58gX}zU`daB#J3#nHRh{PX|v*?1o zvK=!B+QsM6Px77N8pucdRla0gSS*;xPb#!4o>_4OYFiu9b6D?;N_bb$CMu8v{wNMN zMssr%4diLyPBl6ZXalqdIsqI*t_S!ds{A3%T;L?YA64TSp!{*Av_Sa-INWH<&6eD7 z$4xifctPW9#tm;6%5y-EjZy%9Q%M_hJc#>4Ky9E7P!|XW+5+u>4nRksGr+PkEW?mK z)#}W)&&fr}E{5O@G`JXuzpltFeG+Ix8ufW6=oTU#y2Lp^Nmskb&CTUcK3B%-kjq8R zl?4i~i-KSPtM>OPEf-GgK4mxot5CL&jzUZdi`KOBZEZu#J z-0?=DJWH=dM4<;ALXjwXJl``>5JnNDJHk~hA{OJ~3YZZ@VV2)R0+q!cEg!OYZm#6( zuCAB^!`+ch7Mk3w+7lkNpL7fXXO7nV(uL8JuSu3{fF#}kYyoN^!e&5MAOuJSCIWTq zVUiVi7-$Ft0gZshKvSR@&;n=)7=eMnV8C5?-T_PtYi^#Xy3(M3SGd0%{MbdpUa9my zY$S|rZzf8Av4icARb28f9$d6n{BcD?t7Hvut77eP`@S78PDay+#d|#7M+IsAnrXq@ z9D!9>QXhd|aIa#GxgCppJno$Benj)@;!>PksSrIqWr&`hGK33eV@qYQRETq4GQ>GA z8N#(FMA^BrRgfWU6=Vpv-^`WXM~2Y*$Pn(onX7}oGQ>e&8N%Z?b2YA#3^A^f4B`2k zxvEt~hNx9VhVc58T)ArXAuF@I5;bQ0`&J^GAX}@0)3NOcE!+8>J-576gN05|{-1$PDegf|k#NsYwTCe20V_ASLqeJfGZ zwT#8I-39l4)#&)B zxYE0?FvRo~maGBdlZK&U(fwiKqsk_+YM@CBXgNqc+-s0{FnN&Z(5B3`t3I6>?V8Y-T4O%bnjNfF*I!$fT7k>Y;8bTKb0L-Y#E z6rQ6jVqr+O*pe|(tnHE`%xRNEtH!xvOV3e66C-Agj0( zX%%fcO%nmVY~rI>o7gtSCLS?AE5`SlEhY?|EuQQ#Py8@!zWA}`0`W+Vh2q~$7mB2b z{}3-GE)k!0ctI>OEEA_q%f$D|%f#2=FA8(Lm&D%oFNqz!UJ}1Vza%OKtrE+ctP;QI zUl!jczARcbeN|*s{+F>-2hl=<2sVZU6e)>s&7EJ{8|}@6xq5KmVv8`a+lP2QIu@Hl})BvOYTxdx%RdKI5`ikqYo=**eh0^S2eL#?hmv7>L!_ z5x^)h7Juo=hNCX&i@w=U7O8$t&r~KJd#Dgmxwv?^y1Ti%xVqV!iIdUe#Ad1=ytIUH zH;G|lBvz<0MIhGml8{~?&U6|Dw*;gN^%?QF2zP7-(s_EgxYOi_7vy;HmCGli7>_V! zgiOO*t7cm26+PTsc$JxYH>@fSLrM(Ov2&qrnyZV>!yC(jxZZOSxi=>6Ye$8LmrfHV zLL6AkHDRNc@7~DqFnnwKD8fSha4#wzsR&JsKa5*+Q4m2{N=h72DaJE3KRP#?u{33x zSe^Z?H0_4`Xd$|_hVKgW?W*NarBajxZZegz!;GPEI3cS$*3Vl(S!;plplDvR2@vS51K?1d}W;4ZbQF3`cx zpkJtt(F1AkTDdFkC0*<Bxd>HfWMA<$v#;0yo|BJco%z!X=l36z8i7h6===xs<|y zHr{V4s}k2QTqrB!@(SaJM=-G_~?8v%Ey(hHo6z!)+Ws zkfi)x2r#z!07xd*04zMm^(@kUfC8T7VSjTGpg{cvV998_gCV~N!kuMrjXU!cf;(nU zM1S0A|4F;EO1Q`4UKw|`U#``U#+~W2zBoMQw-;A!I{enMkGAc8_rUcR|0&&7h0qV< zA3SEl@MZ%ulQQs3x6!NTFCt|>`#Eemyqmsu2h9)Nu zOH2xk9F;K??jeBawY45^A^fxEvt{SrJfhQ{XPaw(qMnEE`Y3nH5n35R?npXP;_#ia zi1+fVoC4((D5pR<1+?DNxqRd z0LSmI0vy}_8{kajJAm^Tzj*xKW7J4|lH=nTdFR|5$IFagyVooFjvT4Em9p+bRb03S z0xS#TW7>}uz4MuQ>t_rN%NR8@D{)NstYK+G2Vo+?Ig1>|x74?3*|bHAX6>4_Y}USg zn|5$2zsf04PJwa?lvALb0_7Aar$9Lc$|+DzfpQ9zQ=psz|9ceRIG2N0j+Z%J=UAQN zbgtWT&7SM^T%+f>nPYFR%X3YhV|sU z4w#h#4S83g z+=g*S2=3j0?m!QqC-5lH3*h(Oxc3420{wvgzyQDigaTneI1m9C0TVC~7z9KDgMlc3 z`x#*FrNub}_gEkfhzAk?Gmr={%_Q8DffRsqD8qo^zzBffZvp8=&wMBT%thP0QLrKj zM9I@}&w$FW^C1Gv}pIiGZ!jEyT2)Xxw@!F!{? z16i8wB+5-L=3Q4*ND5wgt9t z+Z5YRTLpf!?aeneyn65CCiwG&s`uYE#sTKWC&NP+(cf}9?i literal 0 HcmV?d00001 diff --git a/docs/5.开发计划/2.NMPA注册资料法规核查与整改闭环-第二批完整闭环.md b/docs/5.开发计划/2.NMPA注册资料法规核查与整改闭环-第二批完整闭环.md index 8875b5e..26f2dba 100644 --- a/docs/5.开发计划/2.NMPA注册资料法规核查与整改闭环-第二批完整闭环.md +++ b/docs/5.开发计划/2.NMPA注册资料法规核查与整改闭环-第二批完整闭环.md @@ -7,6 +7,7 @@ ```text 适用条件对话选择框 -> waiting_user 暂停恢复 +-> 附件 4 申报资料目录规则对齐 -> 整包复核 -> 缺失项复核 -> mock 通知留痕 @@ -23,7 +24,7 @@ | 阶段 | 名称 | 目标 | 验收 | | --- | --- | --- | --- | | RR2-1 | 适用条件确认 | 对话选择框确认产品类别、注册类型、临床评价路径等 | waiting_user 可暂停恢复 | -| RR2-2 | 核查能力增强 | 扩展章节、一致性、RAG 引用和文本抽取范围 | 复杂样例可识别更多问题 | +| RR2-2 | 附件 4 规则对齐与核查能力增强 | 按《体外诊断试剂注册申报资料要求及说明》扩展完整目录规则、章节、一致性、RAG 引用和文本抽取范围 | 能识别附件 4 一级/二级目录缺失和关键字段问题 | | RR2-3 | 整包复核 | 基于新的汇总批次创建新的法规核查批次 | 可追溯来源批次 | | RR2-4 | 缺失项复核 | 针对原 Issue 执行复核并更新状态 | 生成 review_record | | RR2-5 | mock 通知留痕 | 对 blocking/high/medium 写 mock 通知记录 | 报告展示通知记录 | @@ -71,29 +72,89 @@ pytest tests/test_regulatory_condition.py tests/test_regulatory_frontend.py test --- -## 四、RR2-2 核查能力增强 +## 四、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 | 扩展 YAML 规则中的必需章节和一致性字段 | `rules/nmpa_ivd_registration_v1.yaml` | -| RR2-2-002 | 增强文本抽取,缓存章节候选和字段候选 | `services/text_extract.py` | -| RR2-2-003 | 增强章节核查,支持别名、近似标题和证据片段 | `services/structure_check.py` | -| RR2-2-004 | 增强一致性核查,支持多个来源值和低置信度提示项 | `services/consistency_check.py` | -| RR2-2-005 | RAG 引用写入 `rag_result_json` 过程产物 | `services/rag_citation.py`、`storage.py` | -| RR2-2-006 | 增加测试 | `tests/test_regulatory_structure.py`、`tests/test_regulatory_consistency.py`、`tests/test_regulatory_rag.py` | +| 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_structure.py tests/test_regulatory_consistency.py tests/test_regulatory_rag.py +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 -请增强章节核查、一致性核查和 RAG 过程产物。证据必须包含文件路径、命中片段、字段名或规则 ID,便于人工复核。 +请先将附件 4《体外诊断试剂注册申报资料要求及说明》结构化为规则覆盖清单,再增强 YAML、完整性核查、章节核查、一致性核查和 RAG 过程产物。第二批必须覆盖附件 4 的 1-6 章一级目录和主要二级目录;证据必须包含文件路径、命中片段、字段名或规则 ID,便于人工复核。附件 4 作为核心法规材料,不允许在 RAG 构建中静默跳过。 ``` --- @@ -229,14 +290,15 @@ pytest 第一批主链路已经完成并通过全量测试。 目标: -补齐法规核查完整整改闭环,包括适用条件对话选择框、waiting_user 暂停恢复、整包复核、缺失项复核、mock 通知留痕、增强章节/一致性核查和前端交互。 +补齐法规核查完整整改闭环,包括适用条件对话选择框、waiting_user 暂停恢复、附件 4 申报资料目录规则对齐、整包复核、缺失项复核、mock 通知留痕、增强章节/一致性核查和前端交互。 执行规则: 1. 从第一批完成后的稳定分支创建 codex/YYYYMMDD-NMPA法规核查完整闭环 分支。 2. 按 RR2-1 到 RR2-6 顺序执行。 3. 每阶段完成后运行对应验证命令。 -4. 不接真实飞书 CLI/API。 -5. 不做规则管理前端。 -6. 不做自动填写目标文件。 -7. 最后运行 python manage.py check 和 pytest 全量验收。 +4. RR2-2 必须覆盖附件 4 的 1-6 章一级目录和主要二级目录,不能只保留第一批 5 条 Demo 规则。 +5. 不接真实飞书 CLI/API。 +6. 不做规则管理前端。 +7. 不做自动填写目标文件。 +8. 最后运行 python manage.py check 和 pytest 全量验收。 ``` From 12b476a8ef6eb0f1afc7c6c2c14d52477211cc9e Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 10:37:12 +0800 Subject: [PATCH 046/111] =?UTF-8?q?fix(regulatory):=20=E5=B0=86=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E7=A1=AE=E8=AE=A4=E7=A7=BB=E5=85=A5=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/views.py | 17 ++++++++ templates/home.html | 64 ++++++++++++++++++------------- tests/test_regulatory_frontend.py | 4 ++ 3 files changed, 58 insertions(+), 27 deletions(-) diff --git a/review_agent/views.py b/review_agent/views.py index 429715e..5decbdb 100644 --- a/review_agent/views.py +++ b/review_agent/views.py @@ -43,6 +43,7 @@ def workspace(request: HttpRequest) -> HttpResponse: current = conversations.first() workflow_cards = build_workflow_cards(current) if current else [] + condition_confirmation = build_condition_confirmation(workflow_cards) return render( request, @@ -55,6 +56,7 @@ def workspace(request: HttpRequest) -> HttpResponse: "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, }, ) @@ -154,6 +156,21 @@ def build_workflow_cards(conversation: Conversation) -> list[dict[str, object]]: 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 = [ diff --git a/templates/home.html b/templates/home.html index f8ee602..e3e1132 100644 --- a/templates/home.html +++ b/templates/home.html @@ -124,6 +124,43 @@
      {% endfor %} + {% if condition_confirmation %} +
      +
      AI
      +
      +
      + {% csrf_token %} + 适用条件确认 +

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

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

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

      审核智能体

      @@ -257,33 +294,6 @@ {% if batch.error_message %}

      {{ batch.error_message }}

      {% endif %} - {% if batch.workflow_type == "regulatory_review" and batch.status == "waiting_user" and batch.condition_candidates %} -
      - {% csrf_token %} - 适用条件确认 - {% for field, config in batch.condition_candidates.items %} - - {% endfor %} - -

      -
      - {% endif %}
        {% for node in batch.nodes %}
      1. diff --git a/tests/test_regulatory_frontend.py b/tests/test_regulatory_frontend.py index 1fac3e7..188dc34 100644 --- a/tests/test_regulatory_frontend.py +++ b/tests/test_regulatory_frontend.py @@ -102,6 +102,10 @@ def test_workspace_renders_condition_confirmation_form(client, django_user_model 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_renders_rectification_actions_and_summaries(client, tmp_path, django_user_model): From 462d3ec5f5614440c0fb261c9c0ebf29142aa06a Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 10:40:05 +0800 Subject: [PATCH 047/111] =?UTF-8?q?fix(regulatory):=20=E4=BC=98=E5=85=88?= =?UTF-8?q?=E4=BB=8E=E9=99=84=E4=BB=B6=E5=AD=97=E6=AE=B5=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E9=80=82=E7=94=A8=E6=9D=A1=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/info_extract.py | 37 +++++++++++++++++-- tests/test_regulatory_condition.py | 33 +++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/review_agent/regulatory_review/services/info_extract.py b/review_agent/regulatory_review/services/info_extract.py index 7e23a3a..29ebbe5 100644 --- a/review_agent/regulatory_review/services/info_extract.py +++ b/review_agent/regulatory_review/services/info_extract.py @@ -1,6 +1,11 @@ from __future__ import annotations +from pathlib import Path + +from django.conf import settings + from review_agent.models import FileSummaryBatch +from review_agent.regulatory_review.services.text_extract import extract_text OPTION_FIELDS = { @@ -14,9 +19,14 @@ def detect_regulatory_condition_candidates(summary_batch: FileSummaryBatch) -> d """Infers review-scope conditions from the summary batch and file names.""" corpus_parts = [summary_batch.product_name or ""] + field_candidates: 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]) + extracted = _extract_item_fields(item) + field_candidates.update({key: value for key, value in extracted.items() if value and key not in field_candidates}) + corpus_parts.extend(extracted.values()) 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": { @@ -40,21 +50,42 @@ def detect_regulatory_condition_candidates(summary_batch: FileSummaryBatch) -> d "product_name": { "label": "产品名称", "input_type": "text", - "suggested": summary_batch.product_name or "", + "suggested": product_name, }, "model_spec": { "label": "型号规格", "input_type": "text", - "suggested": "", + "suggested": field_candidates.get("型号规格", ""), }, "intended_use": { "label": "预期用途", "input_type": "text", - "suggested": "", + "suggested": field_candidates.get("预期用途", ""), }, } +def _extract_item_fields(item) -> dict[str, str]: + 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.field_candidates: + return {} + return result.field_candidates + + +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 _detect_product_category(corpus: str) -> str: if any(keyword in corpus for keyword in ["体外诊断", "检测试剂", "试剂盒", "IVD"]): return "体外诊断试剂" diff --git a/tests/test_regulatory_condition.py b/tests/test_regulatory_condition.py index ccfc7a5..d7c35f4 100644 --- a/tests/test_regulatory_condition.py +++ b/tests/test_regulatory_condition.py @@ -49,6 +49,39 @@ def test_detect_regulatory_condition_candidates_from_summary_items(django_user_m 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_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") From f46d9c5be6467dce9001ccc6cabe306ced78035f Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 11:12:19 +0800 Subject: [PATCH 048/111] =?UTF-8?q?fix(regulatory):=20=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E6=A0=87=E9=A2=98=E6=98=BE=E7=A4=BA=E7=AB=A0?= =?UTF-8?q?=E8=8A=82=E5=BA=8F=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../regulatory_review/services/completeness_check.py | 11 +++++++++-- .../regulatory_review/services/structure_check.py | 11 +++++++++-- tests/test_regulatory_completeness.py | 3 ++- tests/test_regulatory_structure.py | 1 + 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/review_agent/regulatory_review/services/completeness_check.py b/review_agent/regulatory_review/services/completeness_check.py index 7b2b1ad..c30e11c 100644 --- a/review_agent/regulatory_review/services/completeness_check.py +++ b/review_agent/regulatory_review/services/completeness_check.py @@ -27,8 +27,8 @@ def run_completeness_check(batch: FileSummaryBatch, rule_set: dict) -> list[Find rule_code=requirement["code"], category=requirement.get("category", "completeness"), severity=requirement.get("severity", "medium"), - title=f"缺少{requirement['title']}", - detail=f"当前文件汇总批次未发现{requirement['title']}。", + title=f"缺少{_numbered_title(requirement)}", + detail=f"当前文件汇总批次未发现{_numbered_title(requirement)}。", suggestion=requirement.get("suggestion", ""), evidence={ "requirement_type": requirement.get("type"), @@ -44,3 +44,10 @@ def run_completeness_check(batch: FileSummaryBatch, rule_set: dict) -> list[Find 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/structure_check.py b/review_agent/regulatory_review/services/structure_check.py index d57758a..85f5b27 100644 --- a/review_agent/regulatory_review/services/structure_check.py +++ b/review_agent/regulatory_review/services/structure_check.py @@ -16,8 +16,8 @@ def run_structure_check(document_texts: dict[str, str], rule_set: dict) -> list[ rule_code=requirement["code"], category="structure", severity=requirement.get("severity", "medium"), - title=f"申报资料目录缺少{requirement['title']}章节", - detail=f"未在申报资料目录或章节标题候选中发现{requirement['title']}。", + title=f"申报资料目录缺少{_numbered_title(requirement)}章节", + detail=f"未在申报资料目录或章节标题候选中发现{_numbered_title(requirement)}。", suggestion=requirement.get("suggestion", ""), evidence={ "attachment4_code": requirement.get("attachment4_code"), @@ -68,3 +68,10 @@ def _contains_any(text: str, needles: list[str]) -> bool: 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/tests/test_regulatory_completeness.py b/tests/test_regulatory_completeness.py index 16467bb..51944fc 100644 --- a/tests/test_regulatory_completeness.py +++ b/tests/test_regulatory_completeness.py @@ -37,7 +37,7 @@ def test_completeness_check_matches_existing_files_and_reports_missing(django_us findings = run_completeness_check(batch, load_rule_file()) titles = [finding.title for finding in findings] - assert "缺少注册检验报告" in titles + 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" @@ -67,5 +67,6 @@ def test_completeness_check_matches_attachment4_directory_names(django_user_mode 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_structure.py b/tests/test_regulatory_structure.py index e883918..ac571ff 100644 --- a/tests/test_regulatory_structure.py +++ b/tests/test_regulatory_structure.py @@ -22,4 +22,5 @@ def test_structure_check_reports_missing_attachment4_outline_heading(): 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"] == "临床评价资料" From b8d711729d7b7094d76f782779b83068cde40e84 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 11:17:57 +0800 Subject: [PATCH 049/111] =?UTF-8?q?feat(regulatory):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=8C=89=E9=99=84=E4=BB=B64=E7=AB=A0=E8=8A=82=E6=A0=B8?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/regulatory_review/workflow.py | 65 +++++++++++++++++++++- tests/test_regulatory_workflow.py | 58 +++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/review_agent/regulatory_review/workflow.py b/review_agent/regulatory_review/workflow.py index 6a0e135..4b70bdf 100644 --- a/review_agent/regulatory_review/workflow.py +++ b/review_agent/regulatory_review/workflow.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import logging +import re from pathlib import Path from threading import Thread from uuid import uuid4 @@ -48,6 +49,16 @@ NODE_DEFINITIONS = [ 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 @@ -89,6 +100,7 @@ def create_regulatory_review_batch( 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( @@ -173,7 +185,7 @@ class RegulatoryWorkflowExecutor: self._pause_for_condition_confirmation() return if node_code == "rule_scope": - self.rule_set = load_rule_file() + self.rule_set = apply_rule_scope(load_rule_file(), self.batch.condition_json.get("rule_scope") or {}) return if node_code == "completeness_check": self.findings.extend(run_completeness_check(self.batch.source_summary_batch, self._rules())) @@ -258,7 +270,7 @@ class RegulatoryWorkflowExecutor: def _rules(self) -> dict: if self.rule_set is None: - self.rule_set = load_rule_file() + 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) -> dict[str, str]: @@ -298,3 +310,52 @@ def start_regulatory_review_workflow(batch: RegulatoryReviewBatch, *, 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/tests/test_regulatory_workflow.py b/tests/test_regulatory_workflow.py index 51eefeb..98dcb2a 100644 --- a/tests/test_regulatory_workflow.py +++ b/tests/test_regulatory_workflow.py @@ -163,6 +163,64 @@ def test_stream_message_starts_regulatory_workflow(monkeypatch, settings, django 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 From 72f18167c5989de18bfd7b59b724cdb00ce5df5c Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 11:27:12 +0800 Subject: [PATCH 050/111] =?UTF-8?q?fix(regulatory):=20=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E6=97=B6=E5=8A=A0=E8=BD=BD=E6=9D=A1=E4=BB=B6=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/regulatory_review/views.py | 12 +++-- static/js/app.js | 71 +++++++++++++++++++++++++ tests/test_regulatory_frontend.py | 2 + tests/test_regulatory_views.py | 37 +++++++++++++ 4 files changed, 119 insertions(+), 3 deletions(-) diff --git a/review_agent/regulatory_review/views.py b/review_agent/regulatory_review/views.py index 86c8dcd..b244421 100644 --- a/review_agent/regulatory_review/views.py +++ b/review_agent/regulatory_review/views.py @@ -23,8 +23,7 @@ def batch_status(request, batch_id: int): workflow_type="regulatory_review", workflow_batch_id=batch.pk, ).order_by("id") - return JsonResponse( - { + payload = { "batch": { "id": batch.pk, "workflow_type": "regulatory_review", @@ -46,7 +45,14 @@ def batch_status(request, batch_id: int): for node in nodes ], } - ) + if batch.status == RegulatoryReviewBatch.Status.WAITING_USER and (batch.condition_json or {}).get("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": batch.condition_json["candidates"], + } + return JsonResponse(payload) @require_http_methods(["POST"]) diff --git a/static/js/app.js b/static/js/app.js index 0717538..67a1478 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -644,6 +644,74 @@ selectWorkflowBatchIndex(activeIndex); } + function ensureConditionConfirmationCard(confirmation) { + if (!chatScroll || !confirmation || !confirmation.candidates) { + return; + } + var cardId = "condition-confirmation-" + confirmation.batch_id; + if (document.getElementById(cardId)) { + return; + } + var article = document.createElement("article"); + article.className = "message assistant"; + article.id = cardId; + 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 renderConditionFields(candidates) { + var html = ""; + Object.keys(candidates || {}).forEach(function (field) { + var config = candidates[field] || {}; + html += ""; + }); + return html; + } + async function refreshWorkflowCard(batchId, workflow_type) { if (!summaryPanel || !batchId) { return ""; @@ -662,6 +730,9 @@ 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, diff --git a/tests/test_regulatory_frontend.py b/tests/test_regulatory_frontend.py index 188dc34..c786a6e 100644 --- a/tests/test_regulatory_frontend.py +++ b/tests/test_regulatory_frontend.py @@ -159,5 +159,7 @@ def test_frontend_selects_status_url_by_workflow_type(): 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 diff --git a/tests/test_regulatory_views.py b/tests/test_regulatory_views.py index 198f9a6..3636f39 100644 --- a/tests/test_regulatory_views.py +++ b/tests/test_regulatory_views.py @@ -43,3 +43,40 @@ def test_regulatory_batch_status_requires_owner(client, django_user_model): 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"] == "体外诊断试剂" From a34684e490142902302cddd23218108bfdf8e57b Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 11:30:48 +0800 Subject: [PATCH 051/111] =?UTF-8?q?fix(regulatory):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=8D=A2=E8=A1=8C=E4=BA=A7=E5=93=81=E5=90=8D=E7=A7=B0=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E4=B8=8D=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/text_extract.py | 33 ++++++++++++++--- tests/test_regulatory_condition.py | 35 +++++++++++++++++++ tests/test_regulatory_text_extract.py | 15 ++++++++ 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/review_agent/regulatory_review/services/text_extract.py b/review_agent/regulatory_review/services/text_extract.py index bd8dfab..3b98e51 100644 --- a/review_agent/regulatory_review/services/text_extract.py +++ b/review_agent/regulatory_review/services/text_extract.py @@ -21,6 +21,7 @@ class ExtractedText: SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx", ".pptx", ".xlsx", ".doc"} +FIELD_LABELS = ["产品名称", "型号规格", "预期用途", "管理类别", "分类编码", "注册类型", "临床评价路径"] def extract_text(path: str | Path) -> ExtractedText: @@ -69,8 +70,32 @@ def _section_candidates(text: str) -> list[str]: def _field_candidates(text: str) -> dict[str, str]: fields = {} - for label in ["产品名称", "型号规格", "预期用途", "管理类别", "分类编码", "注册类型", "临床评价路径"]: - match = re.search(rf"{label}[::]\s*([^\n\r]+)", text) - if match: - fields[label] = " ".join(match.group(1).strip().split()) + 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/tests/test_regulatory_condition.py b/tests/test_regulatory_condition.py index d7c35f4..dfcbd28 100644 --- a/tests/test_regulatory_condition.py +++ b/tests/test_regulatory_condition.py @@ -82,6 +82,41 @@ def test_detect_regulatory_condition_prefers_attachment_fields_over_chapter_titl 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_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") diff --git a/tests/test_regulatory_text_extract.py b/tests/test_regulatory_text_extract.py index 713313f..4979bf6 100644 --- a/tests/test_regulatory_text_extract.py +++ b/tests/test_regulatory_text_extract.py @@ -14,6 +14,21 @@ def test_extract_text_reads_plain_text(tmp_path): 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") From 945669b9c251d970cc504bb0722eb6dce5d2a8ff Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 11:46:55 +0800 Subject: [PATCH 052/111] =?UTF-8?q?feat(regulatory):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E5=AD=97=E6=AE=B5LLM=E5=A4=8D=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/info_extract.py | 18 +- .../regulatory_review/services/llm_review.py | 175 ++++++++++++++++++ tests/test_regulatory_condition.py | 43 +++++ tests/test_regulatory_llm_review.py | 42 +++++ 4 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 review_agent/regulatory_review/services/llm_review.py create mode 100644 tests/test_regulatory_llm_review.py diff --git a/review_agent/regulatory_review/services/info_extract.py b/review_agent/regulatory_review/services/info_extract.py index 29ebbe5..1a48820 100644 --- a/review_agent/regulatory_review/services/info_extract.py +++ b/review_agent/regulatory_review/services/info_extract.py @@ -5,6 +5,7 @@ from pathlib import Path from django.conf import settings from review_agent.models import FileSummaryBatch +from review_agent.regulatory_review.services.llm_review import review_condition_fields from review_agent.regulatory_review.services.text_extract import extract_text @@ -20,10 +21,14 @@ def detect_regulatory_condition_candidates(summary_batch: FileSummaryBatch) -> d 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]) - extracted = _extract_item_fields(item) + 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()) 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) @@ -51,21 +56,24 @@ def detect_regulatory_condition_candidates(summary_batch: FileSummaryBatch) -> d "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, str]: +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 @@ -74,7 +82,11 @@ def _extract_item_fields(item) -> dict[str, str]: result = extract_text(path) if result.status != "success" or not result.field_candidates: return {} - return result.field_candidates + return review_condition_fields( + text=result.front_text or result.text, + rule_fields=result.field_candidates, + file_context=f"{item.directory_level}\n{item.file_name}\n{item.relative_path}", + ) def _safe_summary_product_name(product_name: str) -> str: 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..f712ec9 --- /dev/null +++ b/review_agent/regulatory_review/services/llm_review.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import json +import re +from collections.abc import Callable +from typing import Any + +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 = "" + try: + raw = (completion_func or generate_completion)(_condition_messages(text, rule_fields, file_context), temperature=0.0) + payload = _parse_json_object(raw) + llm_fields = _clean_fields(payload.get("fields") or payload) + status = "success" + except (LLMConfigurationError, LLMRequestError, json.JSONDecodeError, TypeError, ValueError) 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]: + try: + raw = (completion_func or generate_completion)(_workflow_messages(stage, payload), temperature=0.0) + parsed = _parse_json_object(raw) + return { + "status": "success", + "stage": stage, + "result": parsed, + "error_message": "", + } + except (LLMConfigurationError, LLMRequestError, json.JSONDecodeError, TypeError, ValueError) 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 _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()) + 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 + return any(keyword in value for keyword in ["第1章", "第2章", "第3章", "监管信息", "综述资料", "章节目录"]) diff --git a/tests/test_regulatory_condition.py b/tests/test_regulatory_condition.py index dfcbd28..e8bb232 100644 --- a/tests/test_regulatory_condition.py +++ b/tests/test_regulatory_condition.py @@ -117,6 +117,49 @@ def test_detect_regulatory_condition_keeps_wrapped_product_name(settings, tmp_pa 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 + 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_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") diff --git a/tests/test_regulatory_llm_review.py b/tests/test_regulatory_llm_review.py new file mode 100644 index 0000000..0d5ad6e --- /dev/null +++ b/tests/test_regulatory_llm_review.py @@ -0,0 +1,42 @@ +import json + +from review_agent.regulatory_review.services.llm_review import review_condition_fields + + +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" From 8f16675a927b0c8f058be634134608fb8b1c5ff6 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 11:52:54 +0800 Subject: [PATCH 053/111] =?UTF-8?q?feat(regulatory):=20=E4=B8=BA=E6=A0=B8?= =?UTF-8?q?=E6=9F=A5=E6=B5=81=E7=A8=8B=E5=A2=9E=E5=8A=A0LLM=E5=A4=8D?= =?UTF-8?q?=E6=A0=B8=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../regulatory_review/services/llm_review.py | 28 +++++++++++ review_agent/regulatory_review/workflow.py | 43 +++++++++++++++-- tests/test_regulatory_condition.py | 1 + tests/test_regulatory_workflow.py | 47 +++++++++++++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/review_agent/regulatory_review/services/llm_review.py b/review_agent/regulatory_review/services/llm_review.py index f712ec9..62357b2 100644 --- a/review_agent/regulatory_review/services/llm_review.py +++ b/review_agent/regulatory_review/services/llm_review.py @@ -1,10 +1,13 @@ from __future__ import annotations import json +import os import re from collections.abc import Callable from typing import Any +from django.conf import settings + from review_agent.llm import LLMConfigurationError, LLMRequestError, generate_completion @@ -22,6 +25,16 @@ def review_condition_fields( 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 = (completion_func or generate_completion)(_condition_messages(text, rule_fields, file_context), temperature=0.0) payload = _parse_json_object(raw) @@ -48,6 +61,13 @@ def review_workflow_payload( 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 = (completion_func or generate_completion)(_workflow_messages(stage, payload), temperature=0.0) parsed = _parse_json_object(raw) @@ -122,6 +142,14 @@ def _parse_json_object(raw: str) -> dict[str, Any]: return parsed +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: diff --git a/review_agent/regulatory_review/workflow.py b/review_agent/regulatory_review/workflow.py index 4b70bdf..09499d2 100644 --- a/review_agent/regulatory_review/workflow.py +++ b/review_agent/regulatory_review/workflow.py @@ -23,6 +23,7 @@ from review_agent.regulatory_review.services.consistency_check import run_consis 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 @@ -121,6 +122,7 @@ class RegulatoryWorkflowExecutor: 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: self.batch.status = RegulatoryReviewBatch.Status.RUNNING @@ -188,10 +190,19 @@ class RegulatoryWorkflowExecutor: self.rule_set = apply_rule_scope(load_rule_file(), self.batch.condition_json.get("rule_scope") or {}) return if node_code == "completeness_check": - self.findings.extend(run_completeness_check(self.batch.source_summary_batch, self._rules())) + findings = run_completeness_check(self.batch.source_summary_batch, self._rules()) + self.findings.extend(findings) + 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() + self._save_llm_review("text_extract", {"files": self.text_extract_status}) save_artifact( self.batch, name="text_extract_status.json", @@ -201,12 +212,17 @@ class RegulatoryWorkflowExecutor: ) return if node_code == "structure_check": - self.findings.extend(run_structure_check(self.document_texts, self._rules())) + findings = run_structure_check(self.document_texts, self._rules()) + self.findings.extend(findings) + self._save_llm_review("structure_check", {"findings": [finding.to_dict() for finding in findings]}) return if node_code == "consistency_check": - self.findings.extend(run_consistency_check(self.document_texts)) + findings = run_consistency_check(self.document_texts) + self.findings.extend(findings) + 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) save_artifact( @@ -225,6 +241,7 @@ class RegulatoryWorkflowExecutor: } for issue in issues ], + "llm_reviews": self.llm_reviews, }, ensure_ascii=False, indent=2, @@ -290,12 +307,18 @@ class RegulatoryWorkflowExecutor: } 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": result.field_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, } @@ -303,6 +326,18 @@ class RegulatoryWorkflowExecutor: texts[item.file_name] = result.text 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 + 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) diff --git a/tests/test_regulatory_condition.py b/tests/test_regulatory_condition.py index e8bb232..334ba4a 100644 --- a/tests/test_regulatory_condition.py +++ b/tests/test_regulatory_condition.py @@ -121,6 +121,7 @@ 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( diff --git a/tests/test_regulatory_workflow.py b/tests/test_regulatory_workflow.py index 98dcb2a..893b103 100644 --- a/tests/test_regulatory_workflow.py +++ b/tests/test_regulatory_workflow.py @@ -263,3 +263,50 @@ def test_workflow_generates_issues_exports_and_assistant_summary(settings, tmp_p 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 From 911e5378e85911cdc782cbeefa57bf875ae4a96a Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 12:09:02 +0800 Subject: [PATCH 054/111] =?UTF-8?q?fix(regulatory):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E7=A1=AE=E8=AE=A4=E5=AE=9E=E6=97=B6=E8=BD=AE?= =?UTF-8?q?=E8=AF=A2=E5=92=8C=E9=87=8D=E5=A4=8D=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/js/app.js | 18 ++++++++++++++---- templates/home.html | 1 + tests/test_regulatory_frontend.py | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index 67a1478..675ac08 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -649,12 +649,14 @@ 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"); @@ -687,6 +689,14 @@ 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) { @@ -830,9 +840,9 @@ delete workflowPollingTimers[key]; } - function startWorkflowPolling(batchId) { + function startWorkflowPolling(batchId, workflow_type) { var card = workflowCardList ? workflowCardList.querySelector('[data-batch-id="' + batchId + '"]') : null; - var workflow_type = card ? card.getAttribute("data-workflow-type") || "file_summary" : "file_summary"; + 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; @@ -907,7 +917,7 @@ status.textContent = "已确认,工作流继续执行。"; } form.classList.add("confirmed"); - startWorkflowPolling(batchId); + startWorkflowPolling(batchId, "regulatory_review"); await refreshWorkflowCard(batchId, "regulatory_review"); } catch (error) { if (status) { @@ -1050,7 +1060,7 @@ assistantMessage.text.innerHTML = renderAssistantContent(assistantText); } else if (eventName === "workflow_started") { ensureWorkflowCard(payload); - startWorkflowPolling(payload.batch_id); + startWorkflowPolling(payload.batch_id, payload.workflow_type); } else if (eventName === "done") { if (payload.assistant_message_id) { assistantMessage.article.id = "message-" + payload.assistant_message_id; diff --git a/templates/home.html b/templates/home.html index e3e1132..50301fb 100644 --- a/templates/home.html +++ b/templates/home.html @@ -128,6 +128,7 @@
        AI
        diff --git a/tests/test_regulatory_frontend.py b/tests/test_regulatory_frontend.py index c786a6e..9fbef5f 100644 --- a/tests/test_regulatory_frontend.py +++ b/tests/test_regulatory_frontend.py @@ -163,3 +163,20 @@ def test_frontend_selects_status_url_by_workflow_type(): 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 From 1b4a10b5baa3aab97ab3dc4f5d0314be536108db Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 12:17:20 +0800 Subject: [PATCH 055/111] =?UTF-8?q?fix(regulatory):=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E6=B3=95=E8=A7=84=E6=A0=B8=E6=9F=A5=E5=89=8D?= =?UTF-8?q?=E7=BD=AE=E6=B1=87=E6=80=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/services.py | 53 +++++++++++++++++++++++------ static/js/app.js | 14 ++++++-- tests/test_file_summary_frontend.py | 10 ++++++ tests/test_regulatory_workflow.py | 47 ++++++++++++++++++++++++- 4 files changed, 110 insertions(+), 14 deletions(-) diff --git a/review_agent/services.py b/review_agent/services.py index 9ac3729..de72857 100644 --- a/review_agent/services.py +++ b/review_agent/services.py @@ -10,7 +10,7 @@ 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 .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply -from .models import Conversation, FileAttachment, Message +from .models import Conversation, FileAttachment, FileSummaryBatch, Message from .regulatory_review.workflow import ( create_regulatory_review_batch, find_latest_successful_summary_batch, @@ -227,18 +227,51 @@ def stream_message(conversation: Conversation, content: str): if route.starts_regulatory_review: source_summary_batch = find_latest_successful_summary_batch(conversation) if not source_summary_batch: - reply_content = "请先执行自动汇总,生成成功的文件汇总批次后再启动法规核查。" - assistant_message = append_assistant_message(conversation, reply_content) - yield sse_event("chunk", {"delta": reply_content}) + 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( - "done", + "workflow_started", { - "assistant_message_id": assistant_message.pk, - "conversation_id": conversation.pk, - "title": conversation.title, + "workflow_type": "file_summary", + "batch_id": summary_batch.pk, + "batch_no": summary_batch.batch_no, }, ) - return + 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, @@ -249,7 +282,7 @@ def stream_message(conversation: Conversation, content: str): batch, async_run=getattr(settings, "REGULATORY_REVIEW_ASYNC", True), ) - reply_content = f"已启动 NMPA 注册资料法规核查工作流,批次号:{batch.batch_no}。" + reply_content = f"{reply_prefix}已启动 NMPA 注册资料法规核查工作流,批次号:{batch.batch_no}。" assistant_message = append_assistant_message(conversation, reply_content) yield sse_event( "workflow_started", diff --git a/static/js/app.js b/static/js/app.js index 675ac08..d1d4c60 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -310,7 +310,7 @@ function appendConversationMessage(message) { if (!message || document.querySelector('.message[data-message-id="' + message.id + '"]')) { - return; + return false; } var label = message.role === "assistant" ? "AI " : "用户 "; label += document.querySelectorAll(".message").length + 1; @@ -320,6 +320,7 @@ if (message.role === "user") { appendNode(created.article.id, label, true); } + return true; } async function refreshConversationMessages() { @@ -337,14 +338,21 @@ return; } var payload = await response.json(); - (payload.messages || []).forEach(appendConversationMessage); + 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(); - scrollChatToBottom(); + if (appendedCount > 0) { + scrollChatToBottom(); + } } catch (error) { console.error("Conversation message refresh failed", error); } diff --git a/tests/test_file_summary_frontend.py b/tests/test_file_summary_frontend.py index 87b3a88..cd5473d 100644 --- a/tests/test_file_summary_frontend.py +++ b/tests/test_file_summary_frontend.py @@ -187,6 +187,16 @@ def test_frontend_refreshes_generated_workflow_messages(): 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() diff --git a/tests/test_regulatory_workflow.py b/tests/test_regulatory_workflow.py index 893b103..76b0753 100644 --- a/tests/test_regulatory_workflow.py +++ b/tests/test_regulatory_workflow.py @@ -3,6 +3,7 @@ import pytest from review_agent.models import ( Conversation, ExportedSummaryFile, + FileAttachment, FileSummaryBatch, FileSummaryItem, Message, @@ -132,10 +133,54 @@ def test_stream_message_prompts_for_summary_when_missing(monkeypatch, django_use frames = list(stream_message(conversation, "请做法规核查")) joined = "".join(frames) - assert "请先执行自动汇总" in joined + 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") From 9e27c4c684d510b8eb6b1785c11fc84d632aa974 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 12:29:22 +0800 Subject: [PATCH 056/111] =?UTF-8?q?fix(regulatory):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=97=A0=E6=A0=87=E7=AD=BE=E6=96=87=E6=A1=A3=E9=80=82=E7=94=A8?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E5=9B=9E=E6=98=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/info_extract.py | 125 +++++++++++++++++- .../regulatory_review/services/llm_review.py | 4 +- review_agent/regulatory_review/views.py | 6 +- review_agent/views.py | 4 +- tests/test_regulatory_condition.py | 55 ++++++++ tests/test_regulatory_frontend.py | 50 +++++++ tests/test_regulatory_llm_review.py | 15 +++ tests/test_regulatory_views.py | 54 ++++++++ 8 files changed, 305 insertions(+), 8 deletions(-) diff --git a/review_agent/regulatory_review/services/info_extract.py b/review_agent/regulatory_review/services/info_extract.py index 1a48820..90d17f2 100644 --- a/review_agent/regulatory_review/services/info_extract.py +++ b/review_agent/regulatory_review/services/info_extract.py @@ -1,10 +1,11 @@ from __future__ import annotations +import re from pathlib import Path from django.conf import settings -from review_agent.models import FileSummaryBatch +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 @@ -16,6 +17,18 @@ OPTION_FIELDS = { } +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.""" @@ -30,6 +43,8 @@ def detect_regulatory_condition_candidates(summary_batch: FileSummaryBatch) -> d 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) @@ -80,13 +95,22 @@ def _extract_item_fields(item) -> dict[str, object]: if not path.exists(): return {} result = extract_text(path) - if result.status != "success" or not result.field_candidates: + if result.status != "success" or not result.text: return {} - return review_condition_fields( + 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=result.field_candidates, + 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: @@ -98,6 +122,99 @@ def _safe_summary_product_name(product_name: str) -> str: 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 "体外诊断试剂" diff --git a/review_agent/regulatory_review/services/llm_review.py b/review_agent/regulatory_review/services/llm_review.py index 62357b2..b74fd94 100644 --- a/review_agent/regulatory_review/services/llm_review.py +++ b/review_agent/regulatory_review/services/llm_review.py @@ -156,7 +156,7 @@ def _clean_fields(fields: dict[str, Any]) -> dict[str, str]: value = fields.get(label) if not isinstance(value, str): continue - normalized = " ".join(value.strip().split()) + normalized = " ".join(value.strip().split()).replace("(", "(").replace(")", ")") if normalized: clean[label] = normalized return clean @@ -200,4 +200,6 @@ def _better_product_name(candidate: str, current: str) -> bool: 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/views.py b/review_agent/regulatory_review/views.py index b244421..ff52236 100644 --- a/review_agent/regulatory_review/views.py +++ b/review_agent/regulatory_review/views.py @@ -9,6 +9,7 @@ from django.contrib.auth.decorators import login_required from review_agent.models import FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun 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 @@ -19,6 +20,7 @@ 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, @@ -45,12 +47,12 @@ def batch_status(request, batch_id: int): for node in nodes ], } - if batch.status == RegulatoryReviewBatch.Status.WAITING_USER and (batch.condition_json or {}).get("candidates"): + 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": batch.condition_json["candidates"], + "candidates": condition_candidates, } return JsonResponse(payload) diff --git a/review_agent/views.py b/review_agent/views.py index 5decbdb..2f78b2b 100644 --- a/review_agent/views.py +++ b/review_agent/views.py @@ -12,6 +12,7 @@ from .services import ( stream_message, ) from .models import Conversation, FileAttachment, FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun +from .regulatory_review.services.info_extract import ensure_regulatory_condition_candidates @login_required @@ -132,6 +133,7 @@ def build_workflow_cards(conversation: Conversation) -> list[dict[str, object]]: ) regulatory_batches = RegulatoryReviewBatch.objects.filter(conversation=conversation) for batch in regulatory_batches: + condition_candidates = ensure_regulatory_condition_candidates(batch) cards.append( { "id": batch.pk, @@ -141,7 +143,7 @@ def build_workflow_cards(conversation: Conversation) -> list[dict[str, object]]: "error_message": batch.error_message, "risk_label": _format_risk_label(batch.risk_summary or {}), "condition_json": batch.condition_json or {}, - "condition_candidates": (batch.condition_json or {}).get("candidates") 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, diff --git a/tests/test_regulatory_condition.py b/tests/test_regulatory_condition.py index 334ba4a..e397f83 100644 --- a/tests/test_regulatory_condition.py +++ b/tests/test_regulatory_condition.py @@ -161,6 +161,61 @@ def test_detect_regulatory_condition_uses_llm_review_for_better_product_name( 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") diff --git a/tests/test_regulatory_frontend.py b/tests/test_regulatory_frontend.py index 9fbef5f..013920e 100644 --- a/tests/test_regulatory_frontend.py +++ b/tests/test_regulatory_frontend.py @@ -4,6 +4,7 @@ from django.urls import reverse from review_agent.models import ( Conversation, FileSummaryBatch, + FileSummaryItem, RegulatoryArtifact, RegulatoryNotificationRecord, RegulatoryReviewBatch, @@ -108,6 +109,55 @@ def test_workspace_renders_condition_confirmation_form(client, django_user_model 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('home')}?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="会话") diff --git a/tests/test_regulatory_llm_review.py b/tests/test_regulatory_llm_review.py index 0d5ad6e..b35a037 100644 --- a/tests/test_regulatory_llm_review.py +++ b/tests/test_regulatory_llm_review.py @@ -40,3 +40,18 @@ def test_review_condition_fields_falls_back_when_llm_returns_chapter_title(): 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" diff --git a/tests/test_regulatory_views.py b/tests/test_regulatory_views.py index 3636f39..4b507b2 100644 --- a/tests/test_regulatory_views.py +++ b/tests/test_regulatory_views.py @@ -80,3 +80,57 @@ def test_regulatory_batch_status_exposes_condition_confirmation(client, django_u 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法)" From 0f9fb980f20addc91e3c36ef9bb55e84dc9ecf8a Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 13:03:24 +0800 Subject: [PATCH 057/111] =?UTF-8?q?fix(regulatory):=20=E4=B8=BALLM?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E8=B6=85=E6=97=B6=E5=A2=9E=E5=8A=A0=E9=87=8D?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../regulatory_review/services/llm_review.py | 33 ++++++++++++++-- tests/test_regulatory_llm_review.py | 38 ++++++++++++++++++- tests/test_regulatory_workflow.py | 29 ++++++++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/review_agent/regulatory_review/services/llm_review.py b/review_agent/regulatory_review/services/llm_review.py index b74fd94..4e5666d 100644 --- a/review_agent/regulatory_review/services/llm_review.py +++ b/review_agent/regulatory_review/services/llm_review.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import os import re +import time from collections.abc import Callable from typing import Any @@ -36,11 +37,14 @@ def review_condition_fields( "selected_sources": selected_sources, } try: - raw = (completion_func or generate_completion)(_condition_messages(text, rule_fields, file_context), temperature=0.0) + 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) as exc: + except (LLMConfigurationError, LLMRequestError, json.JSONDecodeError, TypeError, ValueError, OSError, TimeoutError) as exc: status = "failed" error_message = str(exc) @@ -69,7 +73,10 @@ def review_workflow_payload( "error_message": "", } try: - raw = (completion_func or generate_completion)(_workflow_messages(stage, payload), temperature=0.0) + raw = _call_completion_with_retries( + completion_func or generate_completion, + _workflow_messages(stage, payload), + ) parsed = _parse_json_object(raw) return { "status": "success", @@ -77,7 +84,7 @@ def review_workflow_payload( "result": parsed, "error_message": "", } - except (LLMConfigurationError, LLMRequestError, json.JSONDecodeError, TypeError, ValueError) as exc: + except (LLMConfigurationError, LLMRequestError, json.JSONDecodeError, TypeError, ValueError, OSError, TimeoutError) as exc: return { "status": "failed", "stage": stage, @@ -142,6 +149,24 @@ def _parse_json_object(raw: str) -> dict[str, Any]: 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) + last_error: Exception | None = None + for attempt in range(1, attempts + 1): + try: + 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 _should_call_llm(completion_func: Callable[..., str] | None) -> bool: if completion_func is not None: return True diff --git a/tests/test_regulatory_llm_review.py b/tests/test_regulatory_llm_review.py index b35a037..85c2dd6 100644 --- a/tests/test_regulatory_llm_review.py +++ b/tests/test_regulatory_llm_review.py @@ -1,6 +1,6 @@ import json -from review_agent.regulatory_review.services.llm_review import review_condition_fields +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(): @@ -55,3 +55,39 @@ def test_review_condition_fields_rejects_garbled_llm_product_name(): 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 diff --git a/tests/test_regulatory_workflow.py b/tests/test_regulatory_workflow.py index 76b0753..886ed60 100644 --- a/tests/test_regulatory_workflow.py +++ b/tests/test_regulatory_workflow.py @@ -118,6 +118,35 @@ def test_start_regulatory_review_workflow_runs_synchronously(django_user_model): ).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_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="会话") From 32d258bb753aa3a00e0fe2c95dcd2817936272ba Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 13:23:55 +0800 Subject: [PATCH 058/111] =?UTF-8?q?feat(regulatory):=20=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E6=B3=95=E8=A7=84=E6=A0=B8=E6=9F=A5=E8=BF=87=E7=A8=8B=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/settings.py | 14 ++++ review_agent/llm.py | 4 +- review_agent/logging_filters.py | 15 ++++ .../regulatory_review/services/llm_review.py | 13 ++++ review_agent/regulatory_review/workflow.py | 78 +++++++++++++++++++ tests/test_logging_filters.py | 31 ++++++++ tests/test_regulatory_llm_review.py | 18 +++++ tests/test_regulatory_workflow.py | 29 +++++++ 8 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 review_agent/logging_filters.py create mode 100644 tests/test_logging_filters.py diff --git a/config/settings.py b/config/settings.py index ad63757..4cb4de2 100644 --- a/config/settings.py +++ b/config/settings.py @@ -115,6 +115,9 @@ REGULATORY_RAG_COLLECTION = os.environ.get( "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", "") SILICONFLOW_EMBEDDING_MODEL = os.environ.get( @@ -126,10 +129,16 @@ SILICONFLOW_EMBEDDING_DIMENSIONS = int(os.environ.get("SILICONFLOW_EMBEDDING_DIM 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": { @@ -143,5 +152,10 @@ LOGGING = { "level": os.environ.get("REVIEW_AGENT_LOG_LEVEL", "INFO"), "propagate": True, }, + "django.server": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, }, } diff --git a/review_agent/llm.py b/review_agent/llm.py index 92e79c1..9057536 100644 --- a/review_agent/llm.py +++ b/review_agent/llm.py @@ -57,7 +57,7 @@ def generate_reply(conversation, user_message: str) -> str: raise LLMRequestError("模型接口返回格式不符合预期。") from exc -def generate_completion(messages: list[dict[str, str]], *, temperature: float = 0.0) -> str: +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: @@ -84,7 +84,7 @@ def generate_completion(messages: list[dict[str, str]], *, temperature: float = ) try: - with request.urlopen(http_request, timeout=60) as response: + 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") 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/regulatory_review/services/llm_review.py b/review_agent/regulatory_review/services/llm_review.py index 4e5666d..9988c60 100644 --- a/review_agent/regulatory_review/services/llm_review.py +++ b/review_agent/regulatory_review/services/llm_review.py @@ -4,6 +4,7 @@ import json import os import re import time +import inspect from collections.abc import Callable from typing import Any @@ -152,9 +153,13 @@ def _parse_json_object(raw: str) -> dict[str, Any]: 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 @@ -167,6 +172,14 @@ def _call_completion_with_retries(completion_func: Callable[..., str], messages: 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 diff --git a/review_agent/regulatory_review/workflow.py b/review_agent/regulatory_review/workflow.py index 09499d2..8e3c62c 100644 --- a/review_agent/regulatory_review/workflow.py +++ b/review_agent/regulatory_review/workflow.py @@ -125,6 +125,7 @@ class RegulatoryWorkflowExecutor: 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"]) @@ -136,6 +137,7 @@ class RegulatoryWorkflowExecutor: 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}) @@ -150,6 +152,7 @@ class RegulatoryWorkflowExecutor: 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 findings=%s", self.batch.batch_no, len(self.findings)) def _nodes(self): return WorkflowNodeRun.objects.filter( @@ -158,6 +161,12 @@ class RegulatoryWorkflowExecutor: ).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() @@ -181,6 +190,13 @@ class RegulatoryWorkflowExecutor: "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 _execute_node(self, node_code: str) -> None: if node_code == "condition_confirm": @@ -188,10 +204,22 @@ class RegulatoryWorkflowExecutor: 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()) 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", { @@ -202,6 +230,12 @@ class RegulatoryWorkflowExecutor: return if node_code == "text_extract": self.document_texts = self._extract_source_texts() + 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, @@ -214,17 +248,35 @@ class RegulatoryWorkflowExecutor: if node_code == "structure_check": findings = run_structure_check(self.document_texts, self._rules()) 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) 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", @@ -251,6 +303,11 @@ class RegulatoryWorkflowExecutor: 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, @@ -261,6 +318,12 @@ class RegulatoryWorkflowExecutor: 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, @@ -297,6 +360,7 @@ class RegulatoryWorkflowExecutor: 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), @@ -324,11 +388,25 @@ class RegulatoryWorkflowExecutor: } 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 ""), + ) 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", 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_llm_review.py b/tests/test_regulatory_llm_review.py index 85c2dd6..b67c762 100644 --- a/tests/test_regulatory_llm_review.py +++ b/tests/test_regulatory_llm_review.py @@ -91,3 +91,21 @@ def test_review_workflow_payload_retries_timeout_before_success(settings): 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_workflow.py b/tests/test_regulatory_workflow.py index 886ed60..9230357 100644 --- a/tests/test_regulatory_workflow.py +++ b/tests/test_regulatory_workflow.py @@ -1,3 +1,5 @@ +import logging + import pytest from review_agent.models import ( @@ -147,6 +149,33 @@ def test_workflow_continues_when_llm_review_times_out(monkeypatch, settings, dja 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="会话") From 3e8720e52197a2e18208c3896b0330d5eebdadfb Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 13:32:06 +0800 Subject: [PATCH 059/111] =?UTF-8?q?feat(regulatory):=20=E6=8C=89=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E5=A4=84=E7=90=86=E6=95=B0=E9=87=8F=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/completeness_check.py | 58 ++++++---- .../services/consistency_check.py | 42 ++++--- .../services/structure_check.py | 57 ++++++---- review_agent/regulatory_review/workflow.py | 104 ++++++++++++++++-- tests/test_regulatory_workflow.py | 87 +++++++++++++++ 5 files changed, 286 insertions(+), 62 deletions(-) diff --git a/review_agent/regulatory_review/services/completeness_check.py b/review_agent/regulatory_review/services/completeness_check.py index c30e11c..47e317f 100644 --- a/review_agent/regulatory_review/services/completeness_check.py +++ b/review_agent/regulatory_review/services/completeness_check.py @@ -1,13 +1,25 @@ 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) -> list[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] = [] - for requirement in rule_set.get("requirements", []): + 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 = [ @@ -20,24 +32,32 @@ def run_completeness_check(batch: FileSummaryBatch, rule_set: dict) -> list[Find [*requirement.get("file_keywords", []), *requirement.get("aliases", [])], ) ] - if matched: - continue - 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 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 diff --git a/review_agent/regulatory_review/services/consistency_check.py b/review_agent/regulatory_review/services/consistency_check.py index 1f24e17..19193aa 100644 --- a/review_agent/regulatory_review/services/consistency_check.py +++ b/review_agent/regulatory_review/services/consistency_check.py @@ -2,6 +2,7 @@ from __future__ import annotations import re from collections import defaultdict +from collections.abc import Callable from review_agent.regulatory_review.schemas import Finding @@ -17,27 +18,40 @@ FIELDS = { } -def run_consistency_check(document_texts: dict[str, str]) -> list[Finding]: +def run_consistency_check( + document_texts: dict[str, str], + progress_callback: Callable[[dict[str, object]], None] | None = None, +) -> list[Finding]: findings: list[Finding] = [] - for label, pattern in FIELDS.items(): + 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: - continue - 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 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 diff --git a/review_agent/regulatory_review/services/structure_check.py b/review_agent/regulatory_review/services/structure_check.py index 85f5b27..efe8a40 100644 --- a/review_agent/regulatory_review/services/structure_check.py +++ b/review_agent/regulatory_review/services/structure_check.py @@ -1,12 +1,20 @@ 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) -> list[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()) - for requirement in rule_set.get("requirements", []): + 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", [])], @@ -27,25 +35,32 @@ def run_structure_check(document_texts: dict[str, str], rule_set: dict) -> list[ ) ) required_sections = requirement.get("required_sections") or [] - if not required_sections: - continue - matching_docs = _matching_documents(document_texts, requirement.get("file_keywords", [])) - if not matching_docs: - continue - 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 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 diff --git a/review_agent/regulatory_review/workflow.py b/review_agent/regulatory_review/workflow.py index 8e3c62c..3b4edbd 100644 --- a/review_agent/regulatory_review/workflow.py +++ b/review_agent/regulatory_review/workflow.py @@ -178,7 +178,7 @@ class RegulatoryWorkflowExecutor: {"node_code": node.node_code, "status": node.status, "progress": node.progress, "message": node.message}, ) - self._execute_node(node.node_code) + self._execute_node(node) node.status = WorkflowNodeRun.Status.SUCCESS node.progress = 100 @@ -198,7 +198,44 @@ class RegulatoryWorkflowExecutor: node.progress, ) - def _execute_node(self, node_code: str) -> None: + 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 @@ -212,7 +249,19 @@ class RegulatoryWorkflowExecutor: ) return if node_code == "completeness_check": - findings = run_completeness_check(self.batch.source_summary_batch, self._rules()) + 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", @@ -229,7 +278,7 @@ class RegulatoryWorkflowExecutor: ) return if node_code == "text_extract": - self.document_texts = self._extract_source_texts() + 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, @@ -246,7 +295,19 @@ class RegulatoryWorkflowExecutor: ) return if node_code == "structure_check": - findings = run_structure_check(self.document_texts, self._rules()) + 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", @@ -257,7 +318,18 @@ class RegulatoryWorkflowExecutor: 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) + 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", @@ -353,9 +425,11 @@ class RegulatoryWorkflowExecutor: 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) -> dict[str, str]: + def _extract_source_texts(self, node: WorkflowNodeRun | None = None) -> dict[str, str]: texts = {} - for item in self.batch.source_summary_batch.items.order_by("file_index"): + 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 @@ -369,6 +443,13 @@ class RegulatoryWorkflowExecutor: "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( @@ -396,6 +477,13 @@ class RegulatoryWorkflowExecutor: 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]: diff --git a/tests/test_regulatory_workflow.py b/tests/test_regulatory_workflow.py index 9230357..18da71b 100644 --- a/tests/test_regulatory_workflow.py +++ b/tests/test_regulatory_workflow.py @@ -17,6 +17,7 @@ from review_agent.models import ( ) from review_agent.regulatory_review.workflow import ( NODE_DEFINITIONS, + RegulatoryWorkflowExecutor, create_regulatory_review_batch, find_latest_successful_summary_batch, start_regulatory_review_workflow, @@ -413,3 +414,89 @@ def test_workflow_records_llm_review_artifacts_for_review_nodes( 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) From 56225f40d9fa6ff639a309897413612f130531a3 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 17:05:08 +0800 Subject: [PATCH 060/111] =?UTF-8?q?docs(application-form-fill):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=94=B3=E6=8A=A5=E6=96=87=E4=BB=B6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=A1=AB=E8=A1=A8=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../3.产品关键信息提取与申报文件自动填表.md | 394 +++++++++ .../3.产品关键信息提取与申报文件自动填表.md | 816 ++++++++++++++++++ .../3.产品关键信息提取与申报文件自动填表.md | 790 +++++++++++++++++ .../3.产品关键信息提取与申报文件自动填表.md | 433 ++++++++++ .../3.产品关键信息提取与申报文件自动填表.md | 632 ++++++++++++++ docs/6.待办计划/第二阶段暂缓事项.md | 10 +- 6 files changed, 3071 insertions(+), 4 deletions(-) create mode 100644 docs/1.需求分析/3.产品关键信息提取与申报文件自动填表.md create mode 100644 docs/2.功能设计/3.产品关键信息提取与申报文件自动填表.md create mode 100644 docs/3.详细设计/3.产品关键信息提取与申报文件自动填表.md create mode 100644 docs/4.数据库设计/3.产品关键信息提取与申报文件自动填表.md create mode 100644 docs/5.开发计划/3.产品关键信息提取与申报文件自动填表.md 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/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/3.详细设计/3.产品关键信息提取与申报文件自动填表.md b/docs/3.详细设计/3.产品关键信息提取与申报文件自动填表.md new file mode 100644 index 0000000..fa88fe7 --- /dev/null +++ b/docs/3.详细设计/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.数据库设计/3.产品关键信息提取与申报文件自动填表.md b/docs/4.数据库设计/3.产品关键信息提取与申报文件自动填表.md new file mode 100644 index 0000000..f13e417 --- /dev/null +++ b/docs/4.数据库设计/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/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/6.待办计划/第二阶段暂缓事项.md b/docs/6.待办计划/第二阶段暂缓事项.md index c79c941..72d19d9 100644 --- a/docs/6.待办计划/第二阶段暂缓事项.md +++ b/docs/6.待办计划/第二阶段暂缓事项.md @@ -33,10 +33,12 @@ | 编号 | 待办项 | 来源 | 建议优先级 | 说明 | | --- | --- | --- | --- | --- | -| TODO-FILL-001 | 产品关键信息抽取结果确认 | 原始需求 3 | P1 | 将第二阶段抽取字段转成可人工确认的信息表 | -| TODO-FILL-002 | 自动填写目标文件 | 原始需求 3 | P1 | 将确认后的字段写入注册申报表格或对照清单 | -| TODO-FILL-003 | 填写前后差异报告 | 自动填写风控 | P1 | 输出写入前后 diff,供人工复核 | -| TODO-FILL-004 | 自动填写审批确认 | 自动填写风控 | P1 | 文件写操作前必须人工确认 | +| 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 | 文件写操作前支持人工确认或二次审批 | --- From f48277aea7924aa553be9112119661aa6f256365 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 18:16:45 +0800 Subject: [PATCH 061/111] =?UTF-8?q?chore(application-form-fill):=20?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E8=87=AA=E5=8A=A8=E5=A1=AB=E8=A1=A8=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 74cbe349a82f12d283847b77fe12fd31a491cae3 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 18:20:14 +0800 Subject: [PATCH 062/111] =?UTF-8?q?feat(application-form-fill):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=87=AA=E5=8A=A8=E5=A1=AB=E8=A1=A8=E6=89=B9?= =?UTF-8?q?=E6=AC=A1=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/file_summary/views.py | 27 +- ...xportedsummaryfile_export_type_and_more.py | 353 ++++++++++++++++++ review_agent/models.py | 183 +++++++++ tests/test_application_form_fill_models.py | 109 ++++++ tests/test_file_summary_views.py | 49 +++ 5 files changed, 716 insertions(+), 5 deletions(-) create mode 100644 review_agent/migrations/0006_alter_exportedsummaryfile_export_type_and_more.py create mode 100644 tests/test_application_form_fill_models.py diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py index 680d4a3..860c13d 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -7,7 +7,7 @@ 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 Conversation, ExportedSummaryFile, FileAttachment, Message +from review_agent.models import ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileAttachment, Message from review_agent.models import FileSummaryBatch, WorkflowEvent from .events import serialize_event from .paths import resolve_storage_path @@ -271,10 +271,7 @@ def batch_events(request, batch_id: int): @require_http_methods(["GET"]) @login_required def export_download(request, export_id: int): - exported = ExportedSummaryFile.objects.filter( - pk=export_id, - batch__user=request.user, - ).first() + exported = _export_for_user(request.user, export_id) if not exported: raise Http404("导出文件不存在。") path = Path(exported.storage_path) @@ -288,6 +285,8 @@ def export_download(request, export_id: int): 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", } content_type = content_types.get(exported.export_type, "application/octet-stream") logger.info( @@ -305,3 +304,21 @@ def export_download(request, export_id: int): 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.batch.user_id != user.pk: + return None + return exported 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/models.py b/review_agent/models.py index 3cb703e..541a209 100644 --- a/review_agent/models.py +++ b/review_agent/models.py @@ -334,6 +334,8 @@ class ExportedSummaryFile(models.Model): MARKDOWN = "markdown", "Markdown" EXCEL = "excel", "Excel" JSON = "json", "JSON" + WORD = "word", "Word" + PDF = "pdf", "PDF" class Status(models.TextChoices): SUCCESS = "success", "成功" @@ -397,6 +399,92 @@ class RegulatoryRuleVersion(models.Model): return self.code +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 RegulatoryReviewBatch(models.Model): """Tracks one NMPA regulatory review workflow run.""" @@ -571,3 +659,98 @@ class RegulatoryNotificationRecord(models.Model): 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 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"), + ] 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_file_summary_views.py b/tests/test_file_summary_views.py index 6aeaa7f..ec0411f 100644 --- a/tests/test_file_summary_views.py +++ b/tests/test_file_summary_views.py @@ -4,6 +4,7 @@ import json import pytest from review_agent.models import ( + ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileAttachment, @@ -109,6 +110,54 @@ def test_export_download_requires_batch_owner(client, tmp_path, django_user_mode 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") From e48d44f832df8759b5396d85bb2a4b2fca92f239 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 18:23:06 +0800 Subject: [PATCH 063/111] =?UTF-8?q?feat(application-form-fill):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=A8=A1=E6=9D=BF=E9=85=8D=E7=BD=AE=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application_form_fill/__init__.py | 1 + .../application_form_fill/constants.py | 31 +++++ review_agent/application_form_fill/schemas.py | 58 +++++++++ .../services/__init__.py | 1 + .../services/template_config.py | 96 +++++++++++++++ review_agent/application_form_fill/storage.py | 55 +++++++++ .../application_form_templates_v1.yaml | 112 ++++++++++++++++++ review_agent/application_form_fill/views.py | 7 ++ .../application_form_fill/workflow.py | 21 ++++ ...t_application_form_fill_template_config.py | 97 +++++++++++++++ 10 files changed, 479 insertions(+) create mode 100644 review_agent/application_form_fill/__init__.py create mode 100644 review_agent/application_form_fill/constants.py create mode 100644 review_agent/application_form_fill/schemas.py create mode 100644 review_agent/application_form_fill/services/__init__.py create mode 100644 review_agent/application_form_fill/services/template_config.py create mode 100644 review_agent/application_form_fill/storage.py create mode 100644 review_agent/application_form_fill/templates/application_form_templates_v1.yaml create mode 100644 review_agent/application_form_fill/views.py create mode 100644 review_agent/application_form_fill/workflow.py create mode 100644 tests/test_application_form_fill_template_config.py 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..2fc91ba --- /dev/null +++ b/review_agent/application_form_fill/constants.py @@ -0,0 +1,31 @@ +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 = [ + "填注册证", + "对应的表格", + "生成申报模板", + "安全和性能基本原则清单", + "填到申报模板", + "自动填表", + "生成表格", +] + +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/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/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/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..9b106d7 --- /dev/null +++ b/review_agent/application_form_fill/templates/application_form_templates_v1.yaml @@ -0,0 +1,112 @@ +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: 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..510ac0d --- /dev/null +++ b/review_agent/application_form_fill/views.py @@ -0,0 +1,7 @@ +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods + + +@require_http_methods(["GET"]) +def health(request): + return JsonResponse({"workflow_type": "application_form_fill", "status": "available"}) diff --git a/review_agent/application_form_fill/workflow.py b/review_agent/application_form_fill/workflow.py new file mode 100644 index 0000000..78ec271 --- /dev/null +++ b/review_agent/application_form_fill/workflow.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from review_agent.application_form_fill.constants import FORM_FILL_NODE_DEFINITIONS, WORKFLOW_TYPE + + +class FormFillWorkflowExecutor: + """Workflow executor scaffold filled in by later AFF stages.""" + + def __init__(self, batch): + self.batch = batch + + def run(self) -> None: + raise NotImplementedError("application_form_fill workflow is implemented in later AFF stages.") + + +def start_application_form_fill_workflow(batch, *, async_run: bool = True) -> None: + executor = FormFillWorkflowExecutor(batch) + if async_run: + executor.run() + return + executor.run() 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) From 8694f2d43e240242e4e271393100df45347ebcc3 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 18:26:37 +0800 Subject: [PATCH 064/111] =?UTF-8?q?feat(application-form-fill):=20?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E8=87=AA=E5=8A=A8=E5=A1=AB=E8=A1=A8=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/application_form_fill/events.py | 27 +++ .../application_form_fill/workflow.py | 144 ++++++++++++- review_agent/services.py | 84 ++++++++ review_agent/skill_router.py | 25 ++- tests/test_application_form_fill_trigger.py | 45 ++++ tests/test_application_form_fill_workflow.py | 195 ++++++++++++++++++ 6 files changed, 511 insertions(+), 9 deletions(-) create mode 100644 review_agent/application_form_fill/events.py create mode 100644 tests/test_application_form_fill_trigger.py create mode 100644 tests/test_application_form_fill_workflow.py 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/workflow.py b/review_agent/application_form_fill/workflow.py index 78ec271..cb29e6e 100644 --- a/review_agent/application_form_fill/workflow.py +++ b/review_agent/application_form_fill/workflow.py @@ -1,21 +1,151 @@ from __future__ import annotations -from review_agent.application_form_fill.constants import FORM_FILL_NODE_DEFINITIONS, WORKFLOW_TYPE +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.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: - """Workflow executor scaffold filled in by later AFF stages.""" + """Runs the auto-fill workflow skeleton; later stages fill node bodies.""" - def __init__(self, batch): + def __init__(self, batch: ApplicationFormFillBatch): self.batch = batch def run(self) -> None: - raise NotImplementedError("application_form_fill workflow is implemented in later AFF stages.") + 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.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": + 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 + + 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 start_application_form_fill_workflow(batch, *, async_run: bool = True) -> None: +def start_application_form_fill_workflow(batch: ApplicationFormFillBatch, *, async_run: bool = True) -> None: executor = FormFillWorkflowExecutor(batch) - if async_run: + if not async_run: executor.run() return - executor.run() + Thread(target=executor.run, daemon=True).start() diff --git a/review_agent/services.py b/review_agent/services.py index de72857..252502a 100644 --- a/review_agent/services.py +++ b/review_agent/services.py @@ -11,6 +11,11 @@ from .file_summary.skills.attachment_reader import AttachmentReaderSkill from .file_summary.workflow import create_file_summary_batch, start_file_summary_workflow from .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply from .models import Conversation, FileAttachment, FileSummaryBatch, Message +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_review.workflow import ( create_regulatory_review_batch, find_latest_successful_summary_batch, @@ -224,6 +229,85 @@ def stream_message(conversation: Conversation, content: str): ) return + if route.starts_application_form_fill: + source_summary_batch = find_latest_successful_form_fill_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_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_review: source_summary_batch = find_latest_successful_summary_batch(conversation) if not source_summary_batch: diff --git a/review_agent/skill_router.py b/review_agent/skill_router.py index 05718e4..b0b5323 100644 --- a/review_agent/skill_router.py +++ b/review_agent/skill_router.py @@ -8,6 +8,7 @@ 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 @@ -16,6 +17,7 @@ logger = logging.getLogger(__name__) ROUTE_ACTIONS = {"normal_chat", "attachment_reader", "file_summary"} ROUTE_ACTIONS.add("regulatory_review") +ROUTE_ACTIONS.add(FORM_FILL_WORKFLOW_TYPE) @dataclass(frozen=True) @@ -39,6 +41,10 @@ class SkillRoute: 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 is_normal_chat(self) -> bool: return self.action == "normal_chat" @@ -105,7 +111,7 @@ def _route_with_llm( return SkillRoute( action=action, skill_name="attachment_reader" if action == "attachment_reader" else "", - workflow_type=action if action in {"file_summary", "regulatory_review"} else "", + workflow_type=action if action in {"file_summary", "regulatory_review", FORM_FILL_WORKFLOW_TYPE} else "", confidence=_float_or_zero(payload.get("confidence")), reason=str(payload.get("reason") or ""), source="llm", @@ -113,6 +119,15 @@ def _route_with_llm( def _route_with_rules(conversation: Conversation, content: str) -> SkillRoute: + 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", @@ -162,10 +177,11 @@ def _router_system_prompt() -> str: return ( "你是审核智能体的工具路由器,只判断是否需要调用工具,不直接回答用户。" "你必须只输出 JSON 对象,不要输出 Markdown。" - "可选 action:normal_chat、attachment_reader、file_summary、regulatory_review。" + "可选 action:normal_chat、attachment_reader、file_summary、regulatory_review、application_form_fill。" "attachment_reader 用于用户要求阅读、提取、分析、总结、查看上传附件内容。" "file_summary 用于用户要求自动汇总文件目录、页数、清单或生成目录页数报告。" "regulatory_review 用于用户要求法规核查、NMPA核查、完整性核查、章节一致性核查、风险预警或整改建议。" + "application_form_fill 用于用户要求填注册证、生成申报模板、填写对应表格、安全和性能基本原则清单或自动填表。" "normal_chat 用于不需要读取附件或执行工作流的一般问答。" "输出字段:action、confidence、reason。" ) @@ -217,3 +233,8 @@ def _matches_regulatory_review(content: str) -> bool: "一致性核查", ] return any(keyword in normalized for keyword in 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/tests/test_application_form_fill_trigger.py b/tests/test_application_form_fill_trigger.py new file mode 100644 index 0000000..8272f29 --- /dev/null +++ b/tests/test_application_form_fill_trigger.py @@ -0,0 +1,45 @@ +import pytest + +from review_agent.models import Conversation +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" diff --git a/tests/test_application_form_fill_workflow.py b/tests/test_application_form_fill_workflow.py new file mode 100644 index 0000000..4003534 --- /dev/null +++ b/tests/test_application_form_fill_workflow.py @@ -0,0 +1,195 @@ +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, + Message, + WorkflowEvent, + WorkflowNodeRun, +) +from review_agent.services import stream_message +from review_agent.skill_router import SkillRoute + + +pytestmark = pytest.mark.django_db + + +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, + ) + batch = create_application_form_fill_batch( + conversation=conversation, + user=user, + 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_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() From 72890783b3eb1b02e365732ccf4cf7b883eb25cf Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 18:28:48 +0800 Subject: [PATCH 065/111] =?UTF-8?q?feat(application-form-fill):=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=87=AA=E5=8A=A8=E5=A1=AB=E8=A1=A8=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/template_repository.py | 57 +++++++ .../services/template_select.py | 158 ++++++++++++++++++ ...plication_form_fill_template_repository.py | 60 +++++++ ...t_application_form_fill_template_select.py | 114 +++++++++++++ 4 files changed, 389 insertions(+) create mode 100644 review_agent/application_form_fill/services/template_repository.py create mode 100644 review_agent/application_form_fill/services/template_select.py create mode 100644 tests/test_application_form_fill_template_repository.py create mode 100644 tests/test_application_form_fill_template_select.py 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/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" From a48f778e09019fec19ba569d7af0d2c3810de48d Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 18:31:34 +0800 Subject: [PATCH 066/111] =?UTF-8?q?feat(application-form-fill):=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=AD=97=E6=AE=B5=E6=8A=BD=E5=8F=96=E4=B8=8E?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../prompts/field_extract.md | 23 +++ .../services/field_extract.py | 187 ++++++++++++++++++ .../services/field_merge.py | 88 +++++++++ ...est_application_form_fill_field_extract.py | 121 ++++++++++++ .../test_application_form_fill_field_merge.py | 79 ++++++++ 5 files changed, 498 insertions(+) create mode 100644 review_agent/application_form_fill/prompts/field_extract.md create mode 100644 review_agent/application_form_fill/services/field_extract.py create mode 100644 review_agent/application_form_fill/services/field_merge.py create mode 100644 tests/test_application_form_fill_field_extract.py create mode 100644 tests/test_application_form_fill_field_merge.py 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/services/field_extract.py b/review_agent/application_form_fill/services/field_extract.py new file mode 100644 index 0000000..4c72f10 --- /dev/null +++ b/review_agent/application_form_fill/services/field_extract.py @@ -0,0 +1,187 @@ +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 + + +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 = [field["label"] for field in field_defs if field.get("label")] + for file_name, text in texts.items(): + source_role = detect_source_role(file_name, text) + for field in field_defs: + value, evidence = _extract_label_value(text, field["label"], 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_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*[::])" 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 _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..b6c858a --- /dev/null +++ b/review_agent/application_form_fill/services/field_merge.py @@ -0,0 +1,88 @@ +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 "按来源优先级采用最高优先级字段", + } + ) + 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")} 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..08c7b44 --- /dev/null +++ b/tests/test_application_form_fill_field_extract.py @@ -0,0 +1,121 @@ +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_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..a449ad6 --- /dev/null +++ b/tests/test_application_form_fill_field_merge.py @@ -0,0 +1,79 @@ +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 == [] From f35a3ba9b416ac9c20daa2ea634771392909d395 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 18:33:59 +0800 Subject: [PATCH 067/111] =?UTF-8?q?feat(application-form-fill):=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=A1=AB=E8=A1=A8=20Word=20=E5=92=8C?= =?UTF-8?q?=E8=BF=BD=E6=BA=AF=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/traceability_export.py | 145 ++++++++++++++++++ .../services/word_fill.py | 111 ++++++++++++++ ...test_application_form_fill_traceability.py | 85 ++++++++++ tests/test_application_form_fill_word_fill.py | 121 +++++++++++++++ 4 files changed, 462 insertions(+) create mode 100644 review_agent/application_form_fill/services/traceability_export.py create mode 100644 review_agent/application_form_fill/services/word_fill.py create mode 100644 tests/test_application_form_fill_traceability.py create mode 100644 tests/test_application_form_fill_word_fill.py 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..801f56a --- /dev/null +++ b/review_agent/application_form_fill/services/word_fill.py @@ -0,0 +1,111 @@ +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)) + 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 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 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'[\\/:*?"<>|]+', "_", value or "") + return text.strip()[:80] or "output" 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_word_fill.py b/tests/test_application_form_fill_word_fill.py new file mode 100644 index 0000000..264b716 --- /dev/null +++ b/tests/test_application_form_fill_word_fill.py @@ -0,0 +1,121 @@ +import zipfile + +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 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_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() From 9be10ef9905b74937a3a200af2339e7c8c36bc37 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 18:40:04 +0800 Subject: [PATCH 068/111] =?UTF-8?q?feat(application-form-fill):=20?= =?UTF-8?q?=E4=B8=B2=E8=81=94=E5=A1=AB=E8=A1=A8=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E4=BA=A7=E7=89=A9=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/notifier.py | 45 +++++ .../application_form_fill/services/summary.py | 35 ++++ .../application_form_fill/workflow.py | 179 +++++++++++++++++- ...test_application_form_fill_notification.py | 61 ++++++ tests/test_application_form_fill_workflow.py | 77 ++++++++ 5 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 review_agent/application_form_fill/services/notifier.py create mode 100644 review_agent/application_form_fill/services/summary.py create mode 100644 tests/test_application_form_fill_notification.py 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..c3c2969 --- /dev/null +++ b/review_agent/application_form_fill/services/notifier.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from django.utils import timezone + +from review_agent.models import ( + ApplicationFormFillBatch, + ApplicationFormFillNotificationRecord, + ExportedSummaryFile, +) + + +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", + ) + 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, + 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..7501d7b --- /dev/null +++ b/review_agent/application_form_fill/services/summary.py @@ -0,0 +1,35 @@ +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"{value.get('source_file', '')}:{value.get('value', '')}" for value in item.get("conflict_values", []) + ) + lines.append( + f"| {item.get('field_label', item.get('field_key', ''))} | {item.get('selected_value', '')} | {conflict_sources or '-'} | {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() diff --git a/review_agent/application_form_fill/workflow.py b/review_agent/application_form_fill/workflow.py index cb29e6e..57699d3 100644 --- a/review_agent/application_form_fill/workflow.py +++ b/review_agent/application_form_fill/workflow.py @@ -10,6 +10,31 @@ 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 @@ -72,6 +97,16 @@ class FormFillWorkflowExecutor: 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) @@ -94,7 +129,9 @@ class FormFillWorkflowExecutor: record_event(self.batch, "workflow_failed", {"message": str(exc)}) return - self.batch.status = ApplicationFormFillBatch.Status.SUCCESS + 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}) @@ -119,6 +156,12 @@ class FormFillWorkflowExecutor: ) 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() @@ -131,6 +174,8 @@ class FormFillWorkflowExecutor: ) return + self._execute_node(node) + node.status = WorkflowNodeRun.Status.SUCCESS node.progress = 100 node.finished_at = timezone.now() @@ -142,6 +187,138 @@ class FormFillWorkflowExecutor: {"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) diff --git a/tests/test_application_form_fill_notification.py b/tests/test_application_form_fill_notification.py new file mode 100644 index 0000000..9905689 --- /dev/null +++ b/tests/test_application_form_fill_notification.py @@ -0,0 +1,61 @@ +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): + 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", + ) + + 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 + + +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_workflow.py b/tests/test_application_form_fill_workflow.py index 4003534..abfe369 100644 --- a/tests/test_application_form_fill_workflow.py +++ b/tests/test_application_form_fill_workflow.py @@ -22,6 +22,14 @@ 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="会话") @@ -83,9 +91,11 @@ def test_application_form_fill_executor_runs_nodes_and_skips_pdf(settings, tmp_p 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, ) @@ -105,6 +115,73 @@ def test_application_form_fill_executor_runs_nodes_and_skips_pdf(settings, tmp_p ).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="会话") From 4ac9c04dbf5cac00e889cd05595f272700d818ac Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 18:43:39 +0800 Subject: [PATCH 069/111] =?UTF-8?q?feat(application-form-fill):=20?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E8=87=AA=E5=8A=A8=E5=A1=AB=E8=A1=A8=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/application_form_fill/views.py | 122 ++++++++++++++++++- review_agent/urls.py | 14 +++ review_agent/views.py | 32 ++++- static/js/app.js | 10 +- templates/home.html | 1 + tests/test_application_form_fill_frontend.py | 48 ++++++++ tests/test_application_form_fill_views.py | 113 +++++++++++++++++ 7 files changed, 335 insertions(+), 5 deletions(-) create mode 100644 tests/test_application_form_fill_frontend.py create mode 100644 tests/test_application_form_fill_views.py diff --git a/review_agent/application_form_fill/views.py b/review_agent/application_form_fill/views.py index 510ac0d..fb147b4 100644 --- a/review_agent/application_form_fill/views.py +++ b/review_agent/application_form_fill/views.py @@ -1,7 +1,127 @@ -from django.http import JsonResponse +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 + @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") + 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 + ], + } + ) + + +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/urls.py b/review_agent/urls.py index 50f4c32..44deeb7 100644 --- a/review_agent/urls.py +++ b/review_agent/urls.py @@ -16,6 +16,10 @@ from .regulatory_review.views import ( 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, +) urlpatterns = [ @@ -84,4 +88,14 @@ urlpatterns = [ 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", + ), ] diff --git a/review_agent/views.py b/review_agent/views.py index 2f78b2b..4b0d3da 100644 --- a/review_agent/views.py +++ b/review_agent/views.py @@ -11,7 +11,7 @@ from .services import ( send_message, stream_message, ) -from .models import Conversation, FileAttachment, FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun +from .models import ApplicationFormFillBatch, Conversation, FileAttachment, FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun from .regulatory_review.services.info_extract import ensure_regulatory_condition_candidates @@ -155,6 +155,25 @@ def build_workflow_cards(conversation: Conversation) -> list[dict[str, object]]: ), } ) + 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") + ), + } + ) return sorted(cards, key=lambda item: item["created_at"], reverse=True)[:5] @@ -187,3 +206,14 @@ def _format_risk_label(risk_summary: dict) -> str: 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) diff --git a/static/js/app.js b/static/js/app.js index d1d4c60..ef6bd75 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -464,8 +464,12 @@ } function statusUrlForWorkflow(workflow_type, batchId) { - var attributeName = - workflow_type === "regulatory_review" ? "data-regulatory-status-url-template" : "data-status-url-template"; + 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"; + } return templateUrl(attributeName, "__batch_id__", batchId); } @@ -832,7 +836,7 @@ } function isWorkflowTerminalStatus(status) { - return status === "success" || status === "failed"; + return status === "success" || status === "partial_success" || status === "failed"; } function workflowTimerKey(batchId, workflow_type) { diff --git a/templates/home.html b/templates/home.html index 50301fb..f6f49a1 100644 --- a/templates/home.html +++ b/templates/home.html @@ -216,6 +216,7 @@ data-message-url-template="/api/review-agent/conversations/__conversation_id__/messages/" data-status-url-template="/api/review-agent/file-summary/__batch_id__/status/" data-regulatory-status-url-template="/api/review-agent/regulatory-review/__batch_id__/status/" + data-application-form-fill-status-url-template="/api/review-agent/application-form-fill/__batch_id__/status/" data-events-url-template="/api/review-agent/file-summary/__batch_id__/events/" >
        diff --git a/tests/test_application_form_fill_frontend.py b/tests/test_application_form_fill_frontend.py new file mode 100644 index 0000000..ae16656 --- /dev/null +++ b/tests/test_application_form_fill_frontend.py @@ -0,0 +1,48 @@ +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('home')}?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 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 From 82c33e513f17a8864bf6288b5c505fee35b89bb3 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 20:14:04 +0800 Subject: [PATCH 070/111] =?UTF-8?q?feat(frontend):=20=E5=90=AF=E7=94=A8?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E5=BF=AB=E6=8D=B7=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/js/app.js | 21 +++++++++++++++++++++ templates/home.html | 18 +++++++++++++++--- tests/test_file_summary_frontend.py | 21 +++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index ef6bd75..a1f4a99 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -965,6 +965,26 @@ }); } + 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) { @@ -1126,6 +1146,7 @@ bindWorkflowBatchCarouselControls(); bindConditionConfirmForms(); bindRectificationActionButtons(); + bindPromptTemplateButtons(); refreshRunningWorkflowCards(); if (chatScroll) { diff --git a/templates/home.html b/templates/home.html index f6f49a1..38fa136 100644 --- a/templates/home.html +++ b/templates/home.html @@ -198,9 +198,21 @@
        - 法规核查 - 说明书审核 - 风险识别 + + +
        diff --git a/tests/test_file_summary_frontend.py b/tests/test_file_summary_frontend.py index cd5473d..5481f5b 100644 --- a/tests/test_file_summary_frontend.py +++ b/tests/test_file_summary_frontend.py @@ -233,3 +233,24 @@ def test_frontend_renders_workflow_batches_as_carousel(): 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('home')}?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 "bindPromptTemplateButtons" in script + assert "promptInput.value = template" in script From ac5cf8bf7ea7f70e6759caa70880cf1ccffb706c Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 20:14:23 +0800 Subject: [PATCH 071/111] =?UTF-8?q?fix(application-form-fill):=20=E4=BC=98?= =?UTF-8?q?=E5=85=88=E8=B7=AF=E7=94=B1=E5=A1=AB=E8=A1=A8=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81rar=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application_form_fill/constants.py | 5 +++ .../services/attachment_reader.py | 44 ++++++++++++++++++- review_agent/skill_router.py | 33 ++++++++++++++ tests/test_application_form_fill_trigger.py | 30 ++++++++++++- tests/test_attachment_reader.py | 35 +++++++++++++++ 5 files changed, 145 insertions(+), 2 deletions(-) diff --git a/review_agent/application_form_fill/constants.py b/review_agent/application_form_fill/constants.py index 2fc91ba..a4082e6 100644 --- a/review_agent/application_form_fill/constants.py +++ b/review_agent/application_form_fill/constants.py @@ -14,6 +14,11 @@ FORM_FILL_TRIGGER_KEYWORDS = [ "填到申报模板", "自动填表", "生成表格", + "申报文件模板", + "申报文件填表", + "产品关键信息", + "字段来源追溯清单", + "注册证 word", ] FORM_FILL_NODE_DEFINITIONS = [ diff --git a/review_agent/file_summary/services/attachment_reader.py b/review_agent/file_summary/services/attachment_reader.py index 8f7cbb5..c8b5c69 100644 --- a/review_agent/file_summary/services/attachment_reader.py +++ b/review_agent/file_summary/services/attachment_reader.py @@ -2,16 +2,18 @@ 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"} +SUPPORTED_EXTENSIONS = TEXT_EXTENSIONS | {"pdf", "docx", "xlsx", "pptx"} | ARCHIVE_EXTENSIONS MAX_PREVIEW_CHARS = 3000 MAX_ROWS_PER_SHEET = 20 @@ -72,6 +74,8 @@ def read_attachment_details(attachment: FileAttachment) -> AttachmentReadResult: 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: @@ -208,6 +212,44 @@ def _read_pptx(path: Path) -> list[dict[str, object]]: 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: diff --git a/review_agent/skill_router.py b/review_agent/skill_router.py index b0b5323..24e668a 100644 --- a/review_agent/skill_router.py +++ b/review_agent/skill_router.py @@ -51,6 +51,10 @@ class SkillRoute: 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) @@ -75,6 +79,35 @@ def route_message_intent(conversation: Conversation, content: str) -> SkillRoute return _route_with_rules(conversation, content) +def _deterministic_workflow_route(conversation: Conversation, content: str) -> SkillRoute | None: + 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, diff --git a/tests/test_application_form_fill_trigger.py b/tests/test_application_form_fill_trigger.py index 8272f29..5235c8a 100644 --- a/tests/test_application_form_fill_trigger.py +++ b/tests/test_application_form_fill_trigger.py @@ -1,6 +1,6 @@ import pytest -from review_agent.models import Conversation +from review_agent.models import Conversation, FileAttachment from review_agent.skill_router import route_message_intent @@ -43,3 +43,31 @@ def test_rule_router_does_not_misroute_normal_chat(monkeypatch, django_user_mode 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_attachment_reader.py b/tests/test_attachment_reader.py index 147f889..84afb97 100644 --- a/tests/test_attachment_reader.py +++ b/tests/test_attachment_reader.py @@ -109,3 +109,38 @@ def test_attachment_reader_skill_returns_structured_details(settings, tmp_path, 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 From 13b543c99d81f2c243f993832b0e9127cebd98e7 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 20:14:37 +0800 Subject: [PATCH 072/111] =?UTF-8?q?fix(application-form-fill):=20=E6=B8=85?= =?UTF-8?q?=E6=B4=97=E5=A1=AB=E8=A1=A8Word=E6=96=87=E4=BB=B6=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/word_fill.py | 3 +- tests/test_application_form_fill_word_fill.py | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/review_agent/application_form_fill/services/word_fill.py b/review_agent/application_form_fill/services/word_fill.py index 801f56a..195b918 100644 --- a/review_agent/application_form_fill/services/word_fill.py +++ b/review_agent/application_form_fill/services/word_fill.py @@ -107,5 +107,6 @@ def _normalize_label(value: str) -> str: def _safe_filename(value: str) -> str: - text = re.sub(r'[\\/:*?"<>|]+', "_", value or "") + text = re.sub(r"[\x00-\x1f\x7f]+", "", value or "") + text = re.sub(r'[\\/:*?"<>|]+', "_", text) return text.strip()[:80] or "output" diff --git a/tests/test_application_form_fill_word_fill.py b/tests/test_application_form_fill_word_fill.py index 264b716..04c918f 100644 --- a/tests/test_application_form_fill_word_fill.py +++ b/tests/test_application_form_fill_word_fill.py @@ -1,4 +1,5 @@ import zipfile +from pathlib import Path import pytest from docx import Document @@ -119,3 +120,31 @@ def test_create_word_export_records_artifact_and_export(settings, tmp_path, djan 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() From 0ccd69d3f46e04b7e0c4c56ff8f0817bc995f4a3 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 20:14:53 +0800 Subject: [PATCH 073/111] =?UTF-8?q?fix(application-form-fill):=20=E6=8A=BD?= =?UTF-8?q?=E5=8F=96=E8=AF=B4=E6=98=8E=E4=B9=A6=E7=AB=A0=E8=8A=82=E5=92=8C?= =?UTF-8?q?=E8=A1=A8=E6=A0=BC=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/field_extract.py | 62 ++++++++++++++++++- .../regulatory_review/services/rag_index.py | 31 +++++++++- ...est_application_form_fill_field_extract.py | 36 +++++++++++ tests/test_regulatory_text_extract.py | 22 +++++++ 4 files changed, 149 insertions(+), 2 deletions(-) diff --git a/review_agent/application_form_fill/services/field_extract.py b/review_agent/application_form_fill/services/field_extract.py index 4c72f10..6f40833 100644 --- a/review_agent/application_form_fill/services/field_extract.py +++ b/review_agent/application_form_fill/services/field_extract.py @@ -15,6 +15,15 @@ from review_agent.models import ApplicationFormFillArtifact, ApplicationFormFill from review_agent.regulatory_review.services.text_extract import extract_text +FIELD_ALIASES = { + "product_name": ["产品名称"], + "package_specification": ["包装规格", "规格"], + "main_components": ["主要组成成分", "主要组成", "组成成分"], + "intended_use": ["预期用途"], + "storage_condition_and_validity": ["产品储存条件及有效期", "储存条件及有效期", "储存条件", "有效期"], +} + + def collect_document_texts(summary_batch: FileSummaryBatch) -> dict[str, str]: texts: dict[str, str] = {} for item in summary_batch.items.order_by("file_index"): @@ -36,7 +45,7 @@ def extract_by_rules(texts: dict[str, str], specs: list[TemplateSpec]) -> dict[s for file_name, text in texts.items(): source_role = detect_source_role(file_name, text) for field in field_defs: - value, evidence = _extract_label_value(text, field["label"], labels) + value, evidence = _extract_field_value(text, field, labels) if not value: continue fields.append( @@ -142,7 +151,34 @@ def _field_defs(specs: list[TemplateSpec]) -> list[dict[str, str]]: 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 _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*[::])" if escaped_labels else r"(?=\Z)" pattern = re.compile(rf"{re.escape(label)}\s*[::]\s*(.+?)(?:{stop_pattern}|\Z)", re.S) @@ -156,6 +192,30 @@ def _extract_label_value(text: str, label: str, labels: list[str]) -> tuple[str, 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") diff --git a/review_agent/regulatory_review/services/rag_index.py b/review_agent/regulatory_review/services/rag_index.py index b6a9d5a..c806e08 100644 --- a/review_agent/regulatory_review/services/rag_index.py +++ b/review_agent/regulatory_review/services/rag_index.py @@ -9,6 +9,10 @@ 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 @@ -49,7 +53,7 @@ def extract_text_from_path(path: Path) -> str: if suffix == ".pdf": return "\n".join(page.extract_text() or "" for page in PdfReader(str(path)).pages) if suffix == ".docx": - return "\n".join(paragraph.text for paragraph in Document(str(path)).paragraphs) + return _extract_docx_text(path) if suffix == ".pptx": presentation = Presentation(str(path)) lines = [] @@ -72,6 +76,31 @@ def extract_text_from_path(path: Path) -> str: 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: with tempfile.TemporaryDirectory() as tmp_dir: target_dir = Path(tmp_dir) diff --git a/tests/test_application_form_fill_field_extract.py b/tests/test_application_form_fill_field_extract.py index 08c7b44..2ceea79 100644 --- a/tests/test_application_form_fill_field_extract.py +++ b/tests/test_application_form_fill_field_extract.py @@ -48,6 +48,42 @@ def test_rule_extracts_registration_certificate_fields(): 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_llm_extract_parses_structured_json(monkeypatch): monkeypatch.setattr( "review_agent.application_form_fill.services.field_extract.generate_completion", diff --git a/tests/test_regulatory_text_extract.py b/tests/test_regulatory_text_extract.py index 4979bf6..a9effe0 100644 --- a/tests/test_regulatory_text_extract.py +++ b/tests/test_regulatory_text_extract.py @@ -37,3 +37,25 @@ def test_extract_text_reports_unsupported_file(tmp_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("【储存条件及有效期】") From 57f9181d58999620589f2b73c015a35e6635cd96 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 20:15:08 +0800 Subject: [PATCH 074/111] =?UTF-8?q?fix(application-form-fill):=20=E6=96=B0?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E5=85=88=E6=B1=87=E6=80=BB=E5=86=8D=E5=A1=AB?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/services.py | 18 +++++- tests/test_application_form_fill_workflow.py | 61 ++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/review_agent/services.py b/review_agent/services.py index 252502a..ac3af8d 100644 --- a/review_agent/services.py +++ b/review_agent/services.py @@ -10,7 +10,7 @@ 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 .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply -from .models import Conversation, FileAttachment, FileSummaryBatch, Message +from .models import Conversation, FileAttachment, FileSummaryBatch, FileSummaryBatchAttachment, Message from .application_form_fill.workflow import ( create_application_form_fill_batch, find_latest_successful_summary_batch as find_latest_successful_form_fill_summary_batch, @@ -231,6 +231,8 @@ def stream_message(conversation: Conversation, content: str): 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 = "请先在当前对话右侧上传需要填表的产品资料或压缩包,我会先自动汇总再继续生成申报模板。" @@ -480,6 +482,20 @@ def _has_active_attachments(conversation: Conversation) -> bool: ) +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 "当前对话没有可读取的附件。" diff --git a/tests/test_application_form_fill_workflow.py b/tests/test_application_form_fill_workflow.py index abfe369..fec7d38 100644 --- a/tests/test_application_form_fill_workflow.py +++ b/tests/test_application_form_fill_workflow.py @@ -11,6 +11,7 @@ from review_agent.models import ( Conversation, FileAttachment, FileSummaryBatch, + FileSummaryBatchAttachment, Message, WorkflowEvent, WorkflowNodeRun, @@ -270,3 +271,63 @@ def test_stream_message_auto_runs_summary_before_application_form_fill( 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 From 30bdcdbc9ca89ce66fd5b33b51ce5ef40509e4df Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 20:19:52 +0800 Subject: [PATCH 075/111] =?UTF-8?q?fix(application-form-fill):=20=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E4=BA=BA=E5=AD=97=E6=AE=B5=E6=9A=82=E7=94=A8=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E4=BC=81=E4=B8=9A=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/field_extract.py | 16 +++++++++- .../services/field_merge.py | 22 +++++++++++++ .../application_form_templates_v1.yaml | 18 +++++++++++ ...est_application_form_fill_field_extract.py | 19 +++++++++++ .../test_application_form_fill_field_merge.py | 32 +++++++++++++++++++ 5 files changed, 106 insertions(+), 1 deletion(-) diff --git a/review_agent/application_form_fill/services/field_extract.py b/review_agent/application_form_fill/services/field_extract.py index 6f40833..82650a0 100644 --- a/review_agent/application_form_fill/services/field_extract.py +++ b/review_agent/application_form_fill/services/field_extract.py @@ -17,6 +17,11 @@ 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": ["预期用途"], @@ -41,7 +46,7 @@ def collect_document_texts(summary_batch: FileSummaryBatch) -> dict[str, str]: 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 = [field["label"] for field in field_defs if field.get("label")] + 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: @@ -174,6 +179,15 @@ def _field_aliases(field: dict[str, str]) -> list[str]: return result +def _all_field_labels(fields: list[dict[str, str]]) -> list[str]: + labels: list[str] = [] + 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) diff --git a/review_agent/application_form_fill/services/field_merge.py b/review_agent/application_form_fill/services/field_merge.py index b6c858a..bbc9eb5 100644 --- a/review_agent/application_form_fill/services/field_merge.py +++ b/review_agent/application_form_fill/services/field_merge.py @@ -81,8 +81,30 @@ def merge_fields(regex_results: dict[str, Any], llm_results: dict[str, Any]) -> "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/templates/application_form_templates_v1.yaml b/review_agent/application_form_fill/templates/application_form_templates_v1.yaml index 9b106d7..75ef0a5 100644 --- a/review_agent/application_form_fill/templates/application_form_templates_v1.yaml +++ b/review_agent/application_form_fill/templates/application_form_templates_v1.yaml @@ -36,6 +36,24 @@ templates: 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: diff --git a/tests/test_application_form_fill_field_extract.py b/tests/test_application_form_fill_field_extract.py index 2ceea79..b1e2b01 100644 --- a/tests/test_application_form_fill_field_extract.py +++ b/tests/test_application_form_fill_field_extract.py @@ -84,6 +84,25 @@ def test_rule_extracts_bracket_sections_from_instructions(): 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_llm_extract_parses_structured_json(monkeypatch): monkeypatch.setattr( "review_agent.application_form_fill.services.field_extract.generate_completion", diff --git a/tests/test_application_form_fill_field_merge.py b/tests/test_application_form_fill_field_merge.py index a449ad6..261f612 100644 --- a/tests/test_application_form_fill_field_merge.py +++ b/tests/test_application_form_fill_field_merge.py @@ -77,3 +77,35 @@ def test_merge_fields_combines_consistent_values_without_conflict(): 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 == [] From d640ced7488f41e96c9b4d09f8406b8b2b4c7d23 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 20:26:32 +0800 Subject: [PATCH 076/111] =?UTF-8?q?fix(application-form-fill):=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=E5=A1=AB=E8=A1=A8=E8=AF=B4=E6=98=8E=E5=B9=B6=E6=94=B6?= =?UTF-8?q?=E7=AA=84=E6=8C=89=E9=92=AE=E8=AF=9D=E6=9C=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/field_extract.py | 13 ++++++-- .../services/word_fill.py | 29 +++++++++++++++++ templates/home.html | 2 +- ...est_application_form_fill_field_extract.py | 21 ++++++++++++ tests/test_application_form_fill_word_fill.py | 32 +++++++++++++++++++ tests/test_file_summary_frontend.py | 3 +- 6 files changed, 96 insertions(+), 4 deletions(-) diff --git a/review_agent/application_form_fill/services/field_extract.py b/review_agent/application_form_fill/services/field_extract.py index 82650a0..7bb636f 100644 --- a/review_agent/application_form_fill/services/field_extract.py +++ b/review_agent/application_form_fill/services/field_extract.py @@ -28,6 +28,15 @@ FIELD_ALIASES = { "storage_condition_and_validity": ["产品储存条件及有效期", "储存条件及有效期", "储存条件", "有效期"], } +STATIC_STOP_LABELS = [ + "申请人", + "国家药品监督管理局", + "填表说明", + "注", + "保证书", + "应附资料", +] + def collect_document_texts(summary_batch: FileSummaryBatch) -> dict[str, str]: texts: dict[str, str] = {} @@ -180,7 +189,7 @@ def _field_aliases(field: dict[str, str]) -> list[str]: def _all_field_labels(fields: list[dict[str, str]]) -> list[str]: - labels: list[str] = [] + labels: list[str] = list(STATIC_STOP_LABELS) for field in fields: for label in _field_aliases(field): if label not in labels: @@ -194,7 +203,7 @@ def _extract_label_value(text: str, label: str, labels: list[str]) -> tuple[str, 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*[::])" if escaped_labels else r"(?=\Z)" + 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: diff --git a/review_agent/application_form_fill/services/word_fill.py b/review_agent/application_form_fill/services/word_fill.py index 195b918..9a6e11a 100644 --- a/review_agent/application_form_fill/services/word_fill.py +++ b/review_agent/application_form_fill/services/word_fill.py @@ -22,6 +22,7 @@ def fill_template( 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 {} @@ -43,6 +44,25 @@ def fill_template( 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: @@ -71,6 +91,15 @@ def apply_cell_shading(cell, fill: str) -> None: 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, diff --git a/templates/home.html b/templates/home.html index 38fa136..ef75d33 100644 --- a/templates/home.html +++ b/templates/home.html @@ -211,7 +211,7 @@
      diff --git a/tests/test_application_form_fill_field_extract.py b/tests/test_application_form_fill_field_extract.py index b1e2b01..28f020b 100644 --- a/tests/test_application_form_fill_field_extract.py +++ b/tests/test_application_form_fill_field_extract.py @@ -103,6 +103,27 @@ def test_rule_maps_agent_fields_to_manufacturer_company_for_now(): 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_llm_extract_parses_structured_json(monkeypatch): monkeypatch.setattr( "review_agent.application_form_fill.services.field_extract.generate_completion", diff --git a/tests/test_application_form_fill_word_fill.py b/tests/test_application_form_fill_word_fill.py index 04c918f..2708e5d 100644 --- a/tests/test_application_form_fill_word_fill.py +++ b/tests/test_application_form_fill_word_fill.py @@ -41,6 +41,17 @@ def _template(path): 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" @@ -61,6 +72,27 @@ def test_word_fill_writes_table_rows(tmp_path): 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" diff --git a/tests/test_file_summary_frontend.py b/tests/test_file_summary_frontend.py index 5481f5b..1355619 100644 --- a/tests/test_file_summary_frontend.py +++ b/tests/test_file_summary_frontend.py @@ -251,6 +251,7 @@ def test_workspace_tool_buttons_fill_default_prompts(client, django_user_model): assert ">风险预警" not in content assert 'data-prompt-template="请对当前对话已上传的文件或压缩包自动汇总文件目录' in content assert 'data-prompt-template="请对当前对话最近成功汇总的注册资料发起 NMPA 法规核查与风险预警' in content - assert 'data-prompt-template="请基于当前对话最近成功汇总的产品资料,自动提取产品关键信息并填入申报文件模板' in content + assert 'data-prompt-template="请基于当前对话最近成功汇总的产品资料,自动提取产品关键信息并填入申报文件模板"' in content + assert "优先生成注册证 Word 和字段来源追溯清单" not in content assert "bindPromptTemplateButtons" in script assert "promptInput.value = template" in script From 003ff592686862c9c1f14e60a675525b46568adb Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 20:34:24 +0800 Subject: [PATCH 077/111] =?UTF-8?q?fix(application-form-fill):=20=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E7=94=B3=E8=AF=B7=E8=A1=A8=E5=99=AA=E5=A3=B0=E5=86=B2?= =?UTF-8?q?=E7=AA=81=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/field_extract.py | 16 ++++++-- .../application_form_fill/services/summary.py | 12 +++++- ...est_application_form_fill_field_extract.py | 20 ++++++++++ tests/test_application_form_fill_summary.py | 39 +++++++++++++++++++ 4 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 tests/test_application_form_fill_summary.py diff --git a/review_agent/application_form_fill/services/field_extract.py b/review_agent/application_form_fill/services/field_extract.py index 7bb636f..35207da 100644 --- a/review_agent/application_form_fill/services/field_extract.py +++ b/review_agent/application_form_fill/services/field_extract.py @@ -17,11 +17,11 @@ from review_agent.regulatory_review.services.text_extract import extract_text FIELD_ALIASES = { "product_name": ["产品名称"], - "applicant_name": ["注册人名称", "生产企业名称", "企业名称", "生产企业"], - "applicant_address": ["注册人住所", "生产企业住所", "企业住所", "住所"], + "applicant_name": ["注册人名称", "申请人名称", "生产企业名称"], + "applicant_address": ["注册人住所", "申请人住所", "生产企业住所"], "manufacturer_address": ["生产地址", "生产企业地址", "生产场所"], - "agent_name": ["代理人名称", "生产企业名称", "企业名称", "生产企业", "注册人名称"], - "agent_address": ["代理人住所", "生产企业住所", "企业住所", "住所", "注册人住所"], + "agent_name": ["代理人名称", "生产企业名称", "注册人名称", "申请人名称"], + "agent_address": ["代理人住所", "生产企业住所", "注册人住所", "申请人住所"], "package_specification": ["包装规格", "规格"], "main_components": ["主要组成成分", "主要组成", "组成成分"], "intended_use": ["预期用途"], @@ -35,6 +35,14 @@ STATIC_STOP_LABELS = [ "注", "保证书", "应附资料", + "优先通道申请", + "分类编码", + "医疗器械唯一标识", + "注册产品目前是否", + "临床评价路径", + "临床试验", + "其他需要说明的问题", + "国家药监局器审中心医疗器械", ] diff --git a/review_agent/application_form_fill/services/summary.py b/review_agent/application_form_fill/services/summary.py index 7501d7b..bb4d663 100644 --- a/review_agent/application_form_fill/services/summary.py +++ b/review_agent/application_form_fill/services/summary.py @@ -22,10 +22,11 @@ def build_assistant_summary(batch: ApplicationFormFillBatch, exports: list[Expor lines.extend(["", "| 冲突字段 | 采用值 | 冲突来源 | 处理 |", "| --- | --- | --- | --- |"]) for item in conflicts: conflict_sources = ";".join( - f"{value.get('source_file', '')}:{value.get('value', '')}" for value in item.get("conflict_values", []) + f"{_compact_table_text(value.get('source_file', ''))}:{_compact_table_text(value.get('value', ''))}" + for value in item.get("conflict_values", []) ) lines.append( - f"| {item.get('field_label', item.get('field_key', ''))} | {item.get('selected_value', '')} | {conflict_sources or '-'} | {item.get('handling', '')} |" + 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: @@ -33,3 +34,10 @@ def build_assistant_summary(batch: ApplicationFormFillBatch, exports: list[Expor 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/tests/test_application_form_fill_field_extract.py b/tests/test_application_form_fill_field_extract.py index 28f020b..4b1494b 100644 --- a/tests/test_application_form_fill_field_extract.py +++ b/tests/test_application_form_fill_field_extract.py @@ -124,6 +124,26 @@ def test_rule_stops_product_name_before_application_form_instructions(): 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", 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 From da81ce24d0b7b78aa23d0eb2ee5d027c97699a4b Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 22:03:05 +0800 Subject: [PATCH 078/111] feat: add feishu notification data models --- config/settings.py | 18 + review_agent/admin.py | 74 ++++ ...sstokencache_feishuusermapping_and_more.py | 352 ++++++++++++++++++ review_agent/models.py | 199 ++++++++++ tests/test_feishu_models.py | 104 ++++++ 5 files changed, 747 insertions(+) create mode 100644 review_agent/admin.py create mode 100644 review_agent/migrations/0007_feishuaccesstokencache_feishuusermapping_and_more.py create mode 100644 tests/test_feishu_models.py diff --git a/config/settings.py b/config/settings.py index 4cb4de2..c996115 100644 --- a/config/settings.py +++ b/config/settings.py @@ -126,6 +126,24 @@ SILICONFLOW_EMBEDDING_MODEL = os.environ.get( ) 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, 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/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/models.py b/review_agent/models.py index 541a209..357ddca 100644 --- a/review_agent/models.py +++ b/review_agent/models.py @@ -754,3 +754,202 @@ class ApplicationFormFillNotificationRecord(models.Model): 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 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/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 From bdc1d58c22cbc17180af5798fdf3d270e5797043 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 22:05:20 +0800 Subject: [PATCH 079/111] feat: add feishu api notification services --- review_agent/notifications/__init__.py | 1 + review_agent/notifications/context.py | 22 ++ .../notifications/feishu_message_api.py | 87 ++++++++ review_agent/notifications/feishu_token.py | 83 ++++++++ review_agent/notifications/message_builder.py | 62 ++++++ review_agent/notifications/recipient.py | 55 +++++ tests/test_feishu_api_services.py | 200 ++++++++++++++++++ 7 files changed, 510 insertions(+) create mode 100644 review_agent/notifications/__init__.py create mode 100644 review_agent/notifications/context.py create mode 100644 review_agent/notifications/feishu_message_api.py create mode 100644 review_agent/notifications/feishu_token.py create mode 100644 review_agent/notifications/message_builder.py create mode 100644 review_agent/notifications/recipient.py create mode 100644 tests/test_feishu_api_services.py 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/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/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/tests/test_feishu_api_services.py b/tests/test_feishu_api_services.py new file mode 100644 index 0000000..b03ce8c --- /dev/null +++ b/tests/test_feishu_api_services.py @@ -0,0 +1,200 @@ +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" + settings.FEISHU_APP_ID = "cli_a" + settings.FEISHU_APP_SECRET = "secret" + 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_token.httpx.post", + lambda *args, **kwargs: FakeResponse({"code": 0, "tenant_access_token": "fresh", "expire": 7200}), + ) + + 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 From 820069f55818d1d857c1fdfaa093d5b71d8768ef Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 22:07:00 +0800 Subject: [PATCH 080/111] feat: add workflow notification dispatcher --- review_agent/notifications/dispatcher.py | 95 +++++++++++ review_agent/notifications/records.py | 114 +++++++++++++ tests/test_feishu_notification_dispatcher.py | 160 +++++++++++++++++++ 3 files changed, 369 insertions(+) create mode 100644 review_agent/notifications/dispatcher.py create mode 100644 review_agent/notifications/records.py create mode 100644 tests/test_feishu_notification_dispatcher.py 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/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/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" From cbc7493df83fa2de22ab8b7a85de27268ce2523e Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 22:09:47 +0800 Subject: [PATCH 081/111] feat: wire feishu notifications into workflows --- .../services/notifier.py | 10 ++ review_agent/file_summary/workflow.py | 13 +++ .../notifications/workflow_adapters.py | 102 ++++++++++++++++++ review_agent/regulatory_review/workflow.py | 13 +++ ...test_application_form_fill_notification.py | 13 ++- tests/test_feishu_workflow_adapters.py | 96 +++++++++++++++++ tests/test_file_summary_workflow.py | 25 +++++ tests/test_regulatory_notification.py | 30 ++++++ 8 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 review_agent/notifications/workflow_adapters.py create mode 100644 tests/test_feishu_workflow_adapters.py diff --git a/review_agent/application_form_fill/services/notifier.py b/review_agent/application_form_fill/services/notifier.py index c3c2969..0b9c93d 100644 --- a/review_agent/application_form_fill/services/notifier.py +++ b/review_agent/application_form_fill/services/notifier.py @@ -7,6 +7,8 @@ from review_agent.models import ( 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( @@ -33,6 +35,13 @@ def notify_completion( 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, @@ -41,5 +50,6 @@ def notify_completion( 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/file_summary/workflow.py b/review_agent/file_summary/workflow.py index fe5378f..5409b2c 100644 --- a/review_agent/file_summary/workflow.py +++ b/review_agent/file_summary/workflow.py @@ -17,6 +17,8 @@ from review_agent.models import ( 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 @@ -154,14 +156,25 @@ class WorkflowExecutor: 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", 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_review/workflow.py b/review_agent/regulatory_review/workflow.py index 3b4edbd..c9b492c 100644 --- a/review_agent/regulatory_review/workflow.py +++ b/review_agent/regulatory_review/workflow.py @@ -18,6 +18,8 @@ from review_agent.models import ( 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 @@ -146,14 +148,25 @@ class RegulatoryWorkflowExecutor: 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", diff --git a/tests/test_application_form_fill_notification.py b/tests/test_application_form_fill_notification.py index 9905689..86fbeae 100644 --- a/tests/test_application_form_fill_notification.py +++ b/tests/test_application_form_fill_notification.py @@ -13,7 +13,7 @@ from review_agent.models import ( pytestmark = pytest.mark.django_db -def test_notify_completion_records_success(django_user_model): +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") @@ -33,6 +33,16 @@ def test_notify_completion_records_success(django_user_model): 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]) @@ -40,6 +50,7 @@ def test_notify_completion_records_success(django_user_model): 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): 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_workflow.py b/tests/test_file_summary_workflow.py index fbe855d..18feb42 100644 --- a/tests/test_file_summary_workflow.py +++ b/tests/test_file_summary_workflow.py @@ -71,6 +71,31 @@ def test_start_file_summary_workflow_runs_synchronously_for_tests(django_user_mo 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") diff --git a/tests/test_regulatory_notification.py b/tests/test_regulatory_notification.py index e9c51f6..a800be6 100644 --- a/tests/test_regulatory_notification.py +++ b/tests/test_regulatory_notification.py @@ -9,6 +9,7 @@ from review_agent.models import ( ) 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 @@ -77,3 +78,32 @@ def test_notification_records_enter_reports(django_user_model): 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" From 1a1b3ee9d4191b4a4d1b398acc12e07ee632828c Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 22:13:56 +0800 Subject: [PATCH 082/111] feat: show feishu notification status --- review_agent/application_form_fill/views.py | 4 ++ review_agent/file_summary/views.py | 4 ++ review_agent/notifications/presenter.py | 42 ++++++++++++++++++++ review_agent/regulatory_review/views.py | 4 ++ static/css/login.css | 24 +++++++++-- static/js/app.js | 29 ++++++++++++++ tests/test_application_form_fill_frontend.py | 29 ++++++++++++++ tests/test_file_summary_frontend.py | 27 ++++++++++++- tests/test_regulatory_frontend.py | 33 +++++++++++++++ 9 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 review_agent/notifications/presenter.py diff --git a/review_agent/application_form_fill/views.py b/review_agent/application_form_fill/views.py index fb147b4..70879ff 100644 --- a/review_agent/application_form_fill/views.py +++ b/review_agent/application_form_fill/views.py @@ -11,6 +11,7 @@ from review_agent.application_form_fill.workflow import ( 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"]) @@ -75,6 +76,7 @@ def batch_status(request, batch_id: int): 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": { @@ -112,6 +114,8 @@ def batch_status(request, batch_id: int): } for export in exports ], + "notifications": notifications, + "latest_notification": notifications[0] if notifications else None, } ) diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py index 860c13d..3fe4120 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -9,6 +9,7 @@ from django.views.decorators.http import require_http_methods from review_agent.models import ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileAttachment, Message 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 @@ -225,6 +226,7 @@ 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": { @@ -249,6 +251,8 @@ def batch_status(request, batch_id: int): } for node in batch.node_runs.order_by("id") ], + "notifications": notifications, + "latest_notification": notifications[0] if notifications else None, } ) 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/regulatory_review/views.py b/review_agent/regulatory_review/views.py index ff52236..c244dea 100644 --- a/review_agent/regulatory_review/views.py +++ b/review_agent/regulatory_review/views.py @@ -8,6 +8,7 @@ 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 @@ -25,6 +26,7 @@ def batch_status(request, batch_id: int): 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, @@ -46,6 +48,8 @@ def batch_status(request, batch_id: int): } 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"] = { diff --git a/static/css/login.css b/static/css/login.css index 212ead0..fa4ded9 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -887,7 +887,8 @@ input:focus { .attachment-item span, .workflow-card em, .workflow-card small, -.workflow-error { +.workflow-error, +.workflow-notification { color: var(--muted); font-size: 12px; } @@ -1042,18 +1043,33 @@ input:focus { .node-status span, .node-status small, -.workflow-error { +.workflow-error, +.workflow-notification { overflow-wrap: anywhere; word-break: break-word; } -.workflow-error { +.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; - line-height: 1.5; } .status-running, diff --git a/static/js/app.js b/static/js/app.js index a1f4a99..58e1230 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -734,6 +734,34 @@ 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 ""; @@ -788,6 +816,7 @@ } else if (riskSummary) { riskSummary.remove(); } + renderNotificationSummary(card, payload.latest_notification); var list = card.querySelector("ol"); list.innerHTML = ""; (payload.nodes || []).forEach(function (node) { diff --git a/tests/test_application_form_fill_frontend.py b/tests/test_application_form_fill_frontend.py index ae16656..7df21f6 100644 --- a/tests/test_application_form_fill_frontend.py +++ b/tests/test_application_form_fill_frontend.py @@ -46,3 +46,32 @@ def test_frontend_selects_application_form_fill_status_url_and_terminal_status() 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_file_summary_frontend.py b/tests/test_file_summary_frontend.py index 1355619..27e9675 100644 --- a/tests/test_file_summary_frontend.py +++ b/tests/test_file_summary_frontend.py @@ -1,7 +1,7 @@ import pytest from django.urls import reverse -from review_agent.models import Conversation, FileAttachment, FileSummaryBatch, Message, WorkflowNodeRun +from review_agent.models import Conversation, FileAttachment, FileSummaryBatch, Message, WorkflowNodeRun, WorkflowNotificationRecord pytestmark = pytest.mark.django_db @@ -223,6 +223,31 @@ def test_frontend_renders_workflow_error_messages(): 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() diff --git a/tests/test_regulatory_frontend.py b/tests/test_regulatory_frontend.py index 013920e..de59447 100644 --- a/tests/test_regulatory_frontend.py +++ b/tests/test_regulatory_frontend.py @@ -8,6 +8,7 @@ from review_agent.models import ( RegulatoryArtifact, RegulatoryNotificationRecord, RegulatoryReviewBatch, + WorkflowNotificationRecord, WorkflowNodeRun, ) @@ -230,3 +231,35 @@ def test_frontend_keeps_single_condition_confirmation_prompt(): 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" From be7fbab0a09f253e9e1f2ec75f71bbb7b35ce8fc Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 22:14:51 +0800 Subject: [PATCH 083/111] feat: add feishu notification test command --- .../commands/send_test_feishu_notification.py | 39 +++++++++++++++++++ tests/test_feishu_management_commands.py | 39 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 review_agent/management/commands/send_test_feishu_notification.py create mode 100644 tests/test_feishu_management_commands.py 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/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") From bd9b2e872efa43e3cfb69a926bb3479eaddff80b Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 22:16:36 +0800 Subject: [PATCH 084/111] feat: add feishu question preview services --- review_agent/feishu_questions/__init__.py | 1 + review_agent/feishu_questions/intent.py | 43 +++++++++ review_agent/feishu_questions/permissions.py | 9 ++ review_agent/feishu_questions/query.py | 85 +++++++++++++++++ review_agent/feishu_questions/service.py | 37 ++++++++ .../commands/feishu_question_simulate.py | 21 +++++ tests/test_feishu_question_reserved.py | 92 +++++++++++++++++++ 7 files changed, 288 insertions(+) create mode 100644 review_agent/feishu_questions/__init__.py create mode 100644 review_agent/feishu_questions/intent.py create mode 100644 review_agent/feishu_questions/permissions.py create mode 100644 review_agent/feishu_questions/query.py create mode 100644 review_agent/feishu_questions/service.py create mode 100644 review_agent/management/commands/feishu_question_simulate.py create mode 100644 tests/test_feishu_question_reserved.py 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/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/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() From f23e403eb8a0eb2667835bc4cfdb1a9a9112764c Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 22:17:40 +0800 Subject: [PATCH 085/111] docs: document feishu configuration --- README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/README.md b/README.md index c4cc26f..021001d 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,43 @@ LibreOffice 不是必需依赖,仅作为未来增强老格式文档解析的 上传原始文件、批次工作目录和导出文件默认存储在 Django `MEDIA_ROOT` 下的 `file_summary/users///` 或批次 `work_dir` 目录中。生产环境 需要把 `MEDIA_ROOT` 挂载到持久化卷,并纳入备份或归档策略。 + +## 飞书通知与问答预留 + +飞书接入使用企业自建应用/智能体的消息 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 +``` From 90144c42ac6c55bcdebbeeb7496f30fe1e5ecc7f Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 22:36:04 +0800 Subject: [PATCH 086/111] =?UTF-8?q?chore(feishu):=20=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E9=A3=9E=E4=B9=A6=E6=8E=A5=E5=85=A5=E6=96=87=E6=A1=A3=E5=92=8C?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 6 + docs/1.需求分析/4.飞书通知与问答接入.md | 527 +++++++++++++++++++ docs/2.功能设计/4.飞书通知与问答接入.md | 292 +++++++++++ docs/3.详细设计/4.飞书通知与问答接入.md | 604 ++++++++++++++++++++++ docs/4.数据库设计/4.飞书通知与问答接入.md | 302 +++++++++++ docs/5.开发计划/4.飞书通知与问答接入.md | 583 +++++++++++++++++++++ 6 files changed, 2314 insertions(+) create mode 100644 docs/1.需求分析/4.飞书通知与问答接入.md create mode 100644 docs/2.功能设计/4.飞书通知与问答接入.md create mode 100644 docs/3.详细设计/4.飞书通知与问答接入.md create mode 100644 docs/4.数据库设计/4.飞书通知与问答接入.md create mode 100644 docs/5.开发计划/4.飞书通知与问答接入.md diff --git a/.env b/.env index e4c4cf5..2167011 100644 --- a/.env +++ b/.env @@ -17,3 +17,9 @@ SCENARIO_CONFIG_DIR=configs GOVERNANCE_CONFIG_PATH=configs/governance.yaml UPLOAD_ROOT=data/uploads CHROMA_PATH=data/chroma +FEISHU_NOTIFY_ENABLED=true +FEISHU_APP_ID=cli_aaafcc59f4b85bc2 +FEISHU_APP_SECRET=OO8GKpjqTO3bHAUwCiSmRgW4FqsNB5Qa +FEISHU_DEFAULT_USER_OPEN_ID=ou_a6015773781a117eb7d8995efa5e4590 +FEISHU_DEFAULT_TARGET_NAME=bruce +PUBLIC_BASE_URL=http://127.0.0.1:8000 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/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/3.详细设计/4.飞书通知与问答接入.md b/docs/3.详细设计/4.飞书通知与问答接入.md new file mode 100644 index 0000000..ec28e5a --- /dev/null +++ b/docs/3.详细设计/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.数据库设计/4.飞书通知与问答接入.md b/docs/4.数据库设计/4.飞书通知与问答接入.md new file mode 100644 index 0000000..55ad261 --- /dev/null +++ b/docs/4.数据库设计/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/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 | From 1f562479783c9eea14be22837627ac1b474d5593 Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 22:43:40 +0800 Subject: [PATCH 087/111] =?UTF-8?q?test(feishu):=20=E4=BF=AE=E6=AD=A3=20to?= =?UTF-8?q?ken=20=E5=88=B7=E6=96=B0=E6=B5=8B=E8=AF=95=20mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_feishu_api_services.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_feishu_api_services.py b/tests/test_feishu_api_services.py index b03ce8c..355558a 100644 --- a/tests/test_feishu_api_services.py +++ b/tests/test_feishu_api_services.py @@ -173,8 +173,6 @@ def test_send_personal_message_api_error(monkeypatch, settings): def test_send_personal_message_refreshes_token_once(monkeypatch, settings): settings.FEISHU_MESSAGE_API_URL = "http://feishu/messages" - settings.FEISHU_APP_ID = "cli_a" - settings.FEISHU_APP_SECRET = "secret" calls = {"message": 0} def fake_message_post(*args, **kwargs): @@ -185,8 +183,12 @@ def test_send_personal_message_refreshes_token_once(monkeypatch, settings): monkeypatch.setattr("review_agent.notifications.feishu_message_api.httpx.post", fake_message_post) monkeypatch.setattr( - "review_agent.notifications.feishu_token.httpx.post", - lambda *args, **kwargs: FakeResponse({"code": 0, "tenant_access_token": "fresh", "expire": 7200}), + "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( From e6fa738fd5e02e5c27d17a5a8735808ec9c89346 Mon Sep 17 00:00:00 2001 From: bruce Date: Mon, 8 Jun 2026 21:35:13 +0800 Subject: [PATCH 088/111] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E4=BA=A7?= =?UTF-8?q?=E5=93=81=E8=AF=B4=E6=98=8E=E5=92=8C=E6=B1=87=E6=8A=A5=E6=9D=90?= =?UTF-8?q?=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PRODUCT.md | 32 +++ docs/7.汇报材料/架构搭建思路汇报稿.md | 333 ++++++++++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 PRODUCT.md create mode 100644 docs/7.汇报材料/架构搭建思路汇报稿.md 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/docs/7.汇报材料/架构搭建思路汇报稿.md b/docs/7.汇报材料/架构搭建思路汇报稿.md new file mode 100644 index 0000000..35040d5 --- /dev/null +++ b/docs/7.汇报材料/架构搭建思路汇报稿.md @@ -0,0 +1,333 @@ +# 架构搭建思路汇报稿(基于 Demo 版) + +## 一、汇报开场 + +各位老师好,我本次 Demo 搭建的是一个面向体外诊断试剂注册资料准备与审核的智能体原型。 + +这个 Demo 的目标不是简单做文件上传、文件解析或问答,而是把注册资料审核中几个高频、耗时、容易出错的环节串成一个可追溯的智能工作流,包括文件目录汇总、法规完整性核查、产品关键信息提取、申报表自动填充,以及异常风险预警。 + +从整体定位上看,它更像是一个“注册资料审核助手”:用户上传一批申报资料后,系统能够先把资料包结构化,再对照法规规则做核查,之后输出风险清单和整改建议,并把抽取到的产品信息继续复用到申报模板填表中。 + +## 二、Demo 运行结果展示 + +本次 Demo 目前可以展示四类核心运行结果。 + +### 1. 文件目录汇总表 + +用户上传注册资料文件夹、散装文件或压缩包后,系统会自动完成附件固化、压缩包解压、文件扫描和页数统计。 + +最终系统会生成 Markdown 汇总报告和 Excel 文件明细表,主要字段包括: + +| 字段 | 说明 | +| --- | --- | +| 序号 | 文件在批次中的顺序 | +| 目录层级 | 文件所在的相对目录 | +| 文件名 | 原始文件名 | +| 类型 | PDF、Word、Excel、PPT 等文件类型 | +| 页数 | PDF 页数、Word 页数、PPT 幻灯片数或 Excel 工作表数 | +| 路径 | 文件在批次工作目录中的相对路径 | +| 状态 | success、failed、unsupported、uncertain 等 | +| 重试次数 | 页数统计失败时的重试记录 | +| 异常说明 | 不支持、不可确定或解析失败的原因 | + +这个结果解决的是资料包进入系统后的第一步问题:先把杂乱的文件夹变成结构化的文件清单。 + +### 2. 法规完整性报告 + +在文件汇总结果基础上,系统会调用法规核查工作流,对照 NMPA 体外诊断试剂注册申报资料要求进行完整性检查。 + +Demo 中使用 `review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml` 作为结构化规则文件。规则文件中配置了附件 4 的资料要求,例如监管信息、综述资料、非临床资料、临床评价资料、说明书和标签样稿、质量管理体系文件等。 + +系统会检查是否缺少关键资料,例如: + +| 检查对象 | 风险示例 | +| --- | --- | +| 注册申请表 | 缺失时生成阻断项或高风险 | +| 符合性声明 | 缺失时生成阻断项 | +| 产品技术要求 | 缺失时生成阻断项 | +| 注册检验报告 | 缺失时生成阻断项 | +| 产品说明书 | 缺失或章节不完整时生成高风险 | +| 标签样稿 | 缺失时生成高风险 | +| 临床评价资料 | 按适用条件生成条件性风险 | +| 质量管理体系文件 | 缺失时生成高风险 | + +最终输出包括 Markdown 法规核查报告、Excel 问题清单和 JSON 结构化结果包。 + +### 3. 信息提取对照表 + +系统会从说明书、产品技术要求、注册检验报告、申请表等文件中抽取产品关键信息。 + +当前 Demo 中重点抽取的字段包括: + +| 字段 | 用途 | +| --- | --- | +| 产品名称 | 用于一致性核查和申报表填充 | +| 型号规格 | 用于跨文件比对 | +| 预期用途 | 用于法规适用条件和模板填充 | +| 管理类别 | 用于法规判断 | +| 分类编码 | 用于注册资料核对 | +| 注册类型 | 用于模板选择和法规规则裁剪 | +| 临床评价路径 | 用于临床资料适用性判断 | + +每个抽取结果都会保留来源文件、来源角色、证据片段、抽取方式和置信度。这样后续生成的填表内容不是黑盒结果,而是能够回溯到原始文件。 + +### 4. 异常预警列表 + +系统会把完整性缺失、章节异常、字段冲突、文本抽取失败、页数不可确定、通知失败等问题统一沉淀为风险项。 + +风险等级目前分为: + +| 风险等级 | 含义 | +| --- | --- | +| 阻断项 | 影响注册资料完整性或关键合规判断,需要优先整改 | +| 高风险 | 可能影响审评,需要重点关注 | +| 中风险 | 建议整改或补充说明 | +| 低风险 | 轻微问题或格式提示 | +| 提示项 | 不直接影响结论,但建议人工确认 | + +例如,如果系统发现不同文件中的“产品名称”或“型号规格”不一致,会生成一致性风险;如果缺少注册检验报告,会生成阻断项,并给出补充注册检验报告的整改建议。 + +## 三、智能体整体工作流 + +结合当前 Demo 的实现,智能体整体工作流可以概括为: + +```text +文件扫描 +-> 目录汇总 +-> 法规匹配 +-> 信息提取 +-> 一致性核查 +-> 风险预警 +-> 报告导出 +-> 通知与整改复核 +``` + +从代码实现上看,系统拆成三条主链路。 + +### 1. 文件汇总链路 + +对应模块:`review_agent/file_summary` + +主要流程为: + +```text +文件上传 +-> 附件固化 +-> 压缩包解压 +-> 文件扫描 +-> 页数统计 +-> 产品名识别 +-> 报告输出 +``` + +这个链路的核心作用是把原始资料包转换成结构化数据。系统会生成 `FileSummaryBatch` 和 `FileSummaryItem`,后续法规核查和自动填表都复用这套文件清单,不再重复扫描文件。 + +### 2. 法规核查链路 + +对应模块:`review_agent/regulatory_review` + +主要流程为: + +```text +准备资料 +-> 适用条件确认 +-> 规则范围裁剪 +-> 完整性核查 +-> 文本抽取 +-> 章节核查 +-> 一致性核查 +-> 风险评估 +-> 报告输出 +``` + +这条链路的核心设计原则是:规则优先,RAG 补依据,LLM 做辅助。 + +也就是说,法规结论不直接交给大模型自由判断,而是优先由结构化规则文件决定;RAG 负责检索法规依据和原文片段;LLM 主要用于低置信度字段抽取、自然语言条件解析和结果复核。 + +### 3. 自动填表链路 + +对应模块:`review_agent/application_form_fill` + +主要流程为: + +```text +准备资料 +-> 模板选择 +-> 模板复制 +-> 字段抽取 +-> 冲突归并 +-> Word 填写 +-> 追溯清单导出 +-> 结果通知 +``` + +这条链路会复用前面抽取到的产品信息,自动选择申报模板,并将字段填入 Word 模板。对于冲突字段,Demo 中采用“说明书优先”的策略,同时在结果中保留冲突摘要和来源追溯。 + +## 四、Demo 实际调用的关键工具和库 + +本 Demo 在工具选型上以轻量、可本地运行、可解释、便于测试为原则。 + +### 1. 文件解析类工具 + +| 工具/库 | Demo 中的用途 | 选用理由 | +| --- | --- | --- | +| `pypdf` | PDF 页数统计和文本抽取 | 轻量、安装简单,适合 Demo 阶段快速处理 PDF | +| `python-docx` | DOCX 文本读取、Word 模板填充 | 可读取段落和表格,也能写入 Word 模板 | +| `python-pptx` | PPTX 幻灯片数量统计和文本读取 | 适合统计幻灯片数量和抽取文本 | +| `openpyxl` | XLSX 工作表统计、Excel 报告导出 | 同时支持读取和生成 Excel | +| `xlrd` | 旧版 XLS 文件读取 | 补充对历史 Excel 格式的支持 | +| `olefile` | 判断老 Office 文件 OLE 结构 | 用于 doc、xls、ppt 等老格式的兜底识别 | +| `py7zr` | 7z 压缩包解压 | 支持常见资料包压缩格式 | +| Python `zipfile` | ZIP 压缩包解压 | 标准库能力,无额外依赖 | + +Demo 中没有选择重型 OCR 或复杂版式引擎,是因为当前阶段重点是打通审核链路和规则闭环。对于扫描件、图片 PDF、复杂版式 PDF,后续可以再接入 OCR 和更强的版式解析能力。 + +### 2. 规则和正则 + +系统使用 YAML 维护法规规则,例如 `nmpa_ivd_registration_v1.yaml`。每条规则包含规则编码、附件 4 编码、标题、资料类型、风险等级、匹配关键词、整改建议和 RAG 检索查询词。 + +正则表达式用于抽取结构化字段,例如: + +```text +产品名称:xxx +型号规格:xxx +预期用途:xxx +管理类别:xxx +分类编码:xxx +``` + +选用规则和正则的原因是:这类注册资料中有大量固定标题和固定字段,使用确定性规则可以提高可解释性,也便于定位问题来源。 + +### 3. RAG 和向量检索 + +Demo 使用 ChromaDB 构建本地法规 RAG 索引。法规原文材料会被切分为文本块,并保存来源文件、chunk 编号等元数据。 + +向量 embedding 支持两种模式: + +| 模式 | 用途 | +| --- | --- | +| SiliconFlow embedding | 用于真实语义检索 | +| deterministic/local embedding | 用于测试和 dry run | + +RAG 在系统中的定位不是直接判断合规,而是为风险问题补充法规依据。例如完整性规则已经判断“缺少注册检验报告”,RAG 再检索相关法规条款,输出来源文件和依据片段,增强报告的可解释性。 + +### 4. LLM 调用 + +LLM 在 Demo 中主要承担辅助角色,包括: + +| 场景 | LLM 作用 | +| --- | --- | +| 自然语言适用条件解析 | 将用户输入转换为结构化字段 | +| 低置信度字段抽取 | 正则抽取不足时补充结构化 JSON | +| 工作流结果复核 | 对中间结果做总结和校验 | +| 整改建议润色 | 在规则模板基础上优化表达 | + +风险等级、法规结论和完整性判断不直接交给 LLM 决定,而是由规则引擎和风险评估服务控制。 + +### 5. 工作流和状态管理 + +系统使用 Django ORM 保存批次、节点、事件和导出文件。 + +关键模型包括: + +| 模型 | 作用 | +| --- | --- | +| `FileSummaryBatch` | 文件汇总批次 | +| `FileSummaryItem` | 文件明细 | +| `RegulatoryReviewBatch` | 法规核查批次 | +| `RegulatoryIssue` | 法规问题和风险项 | +| `RegulatoryArtifact` | 法规核查过程产物 | +| `ApplicationFormFillBatch` | 自动填表批次 | +| `WorkflowNodeRun` | 工作流节点状态 | +| `WorkflowEvent` | SSE 事件和进度记录 | +| `ExportedSummaryFile` | Markdown、Excel、JSON、Word 等导出文件 | + +前端通过 SSE 事件实时展示工作流卡片状态,使用户能够看到每个节点是否正在执行、是否成功、是否等待确认或失败。 + +## 五、难点规则处理方式 + +### 1. 文件完整性检测 + +文件完整性检测的难点在于:注册资料不是固定文件名,企业可能用不同命名方式组织材料。 + +Demo 的处理方式是使用多层匹配: + +```text +规则要求项 +-> 文件名关键词匹配 +-> 相对路径匹配 +-> 目录层级匹配 +-> 必要时结合首页文本和字段候选 +``` + +例如规则中要求“注册检验报告”,系统不仅查找文件名中是否包含“注册检验报告”,也会查找路径和目录中是否包含“检验报告”“检测报告”等别名。 + +如果没有匹配到文件,系统会生成 `Finding`,再由风险评估服务转换为 `RegulatoryIssue`。这样完整性问题既能被结构化记录,也能进入最终风险报告。 + +### 2. 信息一致性核查 + +一致性核查的难点在于:同一个字段可能散落在说明书、注册检验报告、产品技术要求、申请表等多个文件中。 + +Demo 的处理方式是: + +```text +文本抽取 +-> 字段正则识别 +-> 同字段归并 +-> 不同取值比对 +-> 生成一致性风险 +``` + +例如系统会从多个文件中抽取“产品名称”“型号规格”“预期用途”等字段。如果同一字段出现多个不同值,系统会生成高风险问题,并在证据中记录每个取值对应的来源文件。 + +这类结果可以直接辅助人工审核人员定位冲突来源。 + +### 3. 法规条款匹配 + +法规条款匹配的难点在于:法规原文长、条款多,直接让大模型判断容易不稳定,纯规则又缺少解释能力。 + +Demo 采用“双层法规能力”: + +| 层级 | 职责 | +| --- | --- | +| 结构化规则库 | 负责判断应有哪些文件、哪些章节、哪些字段,以及风险等级 | +| RAG 法规依据索引 | 负责检索法规原文片段,补充依据说明 | + +这种设计的好处是:判断逻辑稳定,报告解释充分,后续规则也可以由法规人员维护。 + +### 4. 过程留痕和可追溯 + +审核类系统不能只输出一个结论,还必须说明结论从哪里来。 + +Demo 中对关键过程都做了留痕: + +| 过程 | 留痕内容 | +| --- | --- | +| 文件汇总 | 文件路径、页数、统计状态、异常说明 | +| 文本抽取 | 文本 hash、首页文本、章节候选、字段候选 | +| 完整性核查 | 规则编码、匹配关键词、命中文件或缺失证据 | +| 一致性核查 | 字段值、来源文件、冲突取值 | +| RAG 检索 | 法规来源、片段文本、检索分数 | +| 报告导出 | Markdown、Excel、JSON 结果包 | +| 自动填表 | 字段来源、冲突摘要、追溯清单 | + +这保证了 Demo 输出的结果不是一次性回答,而是可以复核、下载、整改和继续追踪的过程资产。 + +## 六、总结 + +整体来看,本 Demo 的架构搭建思路可以概括为: + +```text +先结构化资料 +再匹配法规 +再抽取字段 +再核查一致性 +再输出风险和报告 +最后支持填表和整改闭环 +``` + +它体现的是一个“资料输入、规则判断、证据追溯、风险输出、整改闭环”的智能体原型。 + +当前 Demo 已经完成了文件汇总、法规完整性核查、信息抽取、风险预警、报告导出和自动填表主链路。后续如果继续增强,可以重点补充 OCR、扫描件识别、复杂 PDF 版式解析、规则后台维护、人工确认界面、飞书真实消息闭环,以及更完整的多智能体编排能力。 + +最终希望这个智能体能够从一个 Demo 原型,逐步演进为注册资料准备和审核过程中的智能协作平台。 From 5ecf78c5d6535a120e20d2a959ca8e682e033407 Mon Sep 17 00:00:00 2001 From: bruce Date: Mon, 8 Jun 2026 21:37:32 +0800 Subject: [PATCH 089/111] =?UTF-8?q?feat(knowledge-base):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=85=A8=E5=B1=80=E7=9F=A5=E8=AF=86=E5=BA=93=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/urls.py | 3 +- review_agent/knowledge_base.py | 397 ++++++++++++++++++ .../migrations/0008_knowledgebasedocument.py | 80 ++++ review_agent/models.py | 39 ++ .../services/rag_citation.py | 1 + .../regulatory_review/services/rag_index.py | 94 +++++ review_agent/urls.py | 32 ++ review_agent/views.py | 108 +++++ static/js/knowledge_base.js | 238 +++++++++++ templates/attachment_manager.html | 2 +- templates/knowledge_base.html | 213 ++++++++++ tests/test_knowledge_base.py | 220 ++++++++++ 12 files changed, 1425 insertions(+), 2 deletions(-) create mode 100644 review_agent/knowledge_base.py create mode 100644 review_agent/migrations/0008_knowledgebasedocument.py create mode 100644 static/js/knowledge_base.js create mode 100644 templates/knowledge_base.html create mode 100644 tests/test_knowledge_base.py diff --git a/config/urls.py b/config/urls.py index 36df95c..caf51ba 100644 --- a/config/urls.py +++ b/config/urls.py @@ -2,10 +2,11 @@ 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, stream_chat, workspace +from review_agent.views import attachment_manager, knowledge_base_manager, stream_chat, workspace urlpatterns = [ path("", workspace, name="home"), + 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"), diff --git a/review_agent/knowledge_base.py b/review_agent/knowledge_base.py new file mode 100644 index 0000000..12edff7 --- /dev/null +++ b/review_agent/knowledge_base.py @@ -0,0 +1,397 @@ +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 DeterministicEmbeddingProvider +from review_agent.regulatory_review.services.rag_index import chunk_text, extract_text_from_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)) + 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=DeterministicEmbeddingProvider(), + 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 = [] + 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: + document.is_active = bool(payload.get("is_active")) + document.status = KnowledgeBaseDocument.Status.ACTIVE if document.is_active else KnowledgeBaseDocument.Status.DISABLED + update_fields.extend(["is_active", "status"]) + if update_fields: + update_fields.append("updated_at") + document.save(update_fields=update_fields) + 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: + 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 = DeterministicEmbeddingProvider()(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/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/models.py b/review_agent/models.py index 357ddca..6189a69 100644 --- a/review_agent/models.py +++ b/review_agent/models.py @@ -399,6 +399,45 @@ class RegulatoryRuleVersion(models.Model): 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.""" diff --git a/review_agent/regulatory_review/services/rag_citation.py b/review_agent/regulatory_review/services/rag_citation.py index 8f54517..7afca0d 100644 --- a/review_agent/regulatory_review/services/rag_citation.py +++ b/review_agent/regulatory_review/services/rag_citation.py @@ -37,6 +37,7 @@ def retrieve_citations( "source": metadata.get("source", "法规材料"), "text": document, "score": distance, + "metadata": metadata, } ) return citations diff --git a/review_agent/regulatory_review/services/rag_index.py b/review_agent/regulatory_review/services/rag_index.py index c806e08..be80cf8 100644 --- a/review_agent/regulatory_review/services/rag_index.py +++ b/review_agent/regulatory_review/services/rag_index.py @@ -2,6 +2,7 @@ from __future__ import annotations import hashlib import logging +import shutil import subprocess import tempfile from dataclasses import dataclass @@ -102,6 +103,33 @@ def _iter_docx_blocks(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: @@ -128,6 +156,72 @@ def _extract_legacy_doc_with_libreoffice(path: Path) -> str: 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("*")): diff --git a/review_agent/urls.py b/review_agent/urls.py index 44deeb7..a2b1d24 100644 --- a/review_agent/urls.py +++ b/review_agent/urls.py @@ -20,6 +20,13 @@ from .application_form_fill.views import ( batch_status as application_form_fill_batch_status, start as application_form_fill_start, ) +from .views import ( + knowledge_base_document_detail, + knowledge_base_document_index, + knowledge_base_documents, + knowledge_base_search, + knowledge_base_status, +) urlpatterns = [ @@ -98,4 +105,29 @@ urlpatterns = [ application_form_fill_batch_status, name="application_form_fill_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/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 index 4b0d3da..f629cfb 100644 --- a/review_agent/views.py +++ b/review_agent/views.py @@ -1,5 +1,7 @@ from django.contrib.auth.decorators import login_required from django.db.models import Count, Q +import json + from django.http import HttpRequest, HttpResponse, JsonResponse, StreamingHttpResponse from django.shortcuts import redirect, render from django.views.decorators.http import require_http_methods @@ -12,6 +14,17 @@ from .services import ( stream_message, ) from .models import ApplicationFormFillBatch, Conversation, FileAttachment, FileSummaryBatch, 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 @@ -94,6 +107,101 @@ def attachment_manager(request: HttpRequest) -> HttpResponse: ) +@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_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: diff --git a/static/js/knowledge_base.js b/static/js/knowledge_base.js new file mode 100644 index 0000000..dd6b9d0 --- /dev/null +++ b/static/js/knowledge_base.js @@ -0,0 +1,238 @@ +(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"); + + 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(); + } + + 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 || "知识库材料操作失败。"); + } + }); + } + + 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 index 72e55dc..5b7fd7c 100644 --- a/templates/attachment_manager.html +++ b/templates/attachment_manager.html @@ -10,8 +10,8 @@ diff --git a/templates/knowledge_base.html b/templates/knowledge_base.html new file mode 100644 index 0000000..efc87cd --- /dev/null +++ b/templates/knowledge_base.html @@ -0,0 +1,213 @@ +{% 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/tests/test_knowledge_base.py b/tests/test_knowledge_base.py new file mode 100644 index 0000000..936d822 --- /dev/null +++ b/tests/test_knowledge_base.py @@ -0,0 +1,220 @@ +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, search_knowledge_base +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" + + +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 + + 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_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 + 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"] From 2244b69d623882ed58e11c10db62c2e73792f281 Mon Sep 17 00:00:00 2001 From: bruce Date: Mon, 8 Jun 2026 21:38:12 +0800 Subject: [PATCH 090/111] =?UTF-8?q?feat(chat):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E7=9F=A5=E8=AF=86=E5=BA=93=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/llm.py | 21 +++-- review_agent/services.py | 125 ++++++++++++++++++++++++++- tests/test_chat_knowledge_context.py | 59 +++++++++++++ tests/test_file_summary_workflow.py | 30 ++++++- tests/test_llm_streaming.py | 15 +++- 5 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 tests/test_chat_knowledge_context.py diff --git a/review_agent/llm.py b/review_agent/llm.py index 9057536..ee2fd5d 100644 --- a/review_agent/llm.py +++ b/review_agent/llm.py @@ -16,7 +16,7 @@ class LLMRequestError(RuntimeError): logger = logging.getLogger(__name__) -def generate_reply(conversation, user_message: str) -> str: +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: @@ -26,7 +26,7 @@ def generate_reply(conversation, user_message: str) -> str: payload = { "model": settings.LLM_MODEL, - "messages": build_messages(conversation, user_message), + "messages": build_messages(conversation, user_message, knowledge_context=knowledge_context), "temperature": 0.3, } body = json.dumps(payload).encode("utf-8") @@ -98,7 +98,7 @@ def generate_completion(messages: list[dict[str, str]], *, temperature: float = raise LLMRequestError("模型接口返回格式不符合预期。") from exc -def stream_reply(conversation, user_message: str): +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: @@ -108,7 +108,7 @@ def stream_reply(conversation, user_message: str): payload = { "model": settings.LLM_MODEL, - "messages": build_messages(conversation, user_message), + "messages": build_messages(conversation, user_message, knowledge_context=knowledge_context), "temperature": 0.3, "stream": True, } @@ -153,10 +153,21 @@ def stream_reply(conversation, user_message: str): raise LLMRequestError(f"模型接口调用失败:{exc.reason}") from exc -def build_messages(conversation, latest_user_message: str) -> list[dict[str, str]]: +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}) diff --git a/review_agent/services.py b/review_agent/services.py index ac3af8d..45b1d74 100644 --- a/review_agent/services.py +++ b/review_agent/services.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import logging +from pathlib import Path from django.db.models import Q, QuerySet from django.conf import settings @@ -9,8 +10,10 @@ 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, Message +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, @@ -104,8 +107,9 @@ def send_message(conversation: Conversation, content: str) -> tuple[Message, Mes """Stores one user message and one provider-backed assistant reply.""" user_message = append_user_message(conversation, content) + knowledge_context = build_knowledge_context(content) try: - reply_content = generate_reply(conversation, content) + reply_content = generate_reply(conversation, content, knowledge_context=knowledge_context) except (LLMConfigurationError, LLMRequestError) as exc: reply_content = f"模型调用失败:{exc}" @@ -391,8 +395,9 @@ def stream_message(conversation: Conversation, content: str): stream_failed = False stream_error = "" + knowledge_context = build_knowledge_context(content) try: - for chunk in stream_reply(conversation, content): + 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: @@ -412,7 +417,7 @@ def stream_message(conversation: Conversation, content: str): if stream_failed: try: - fallback_reply = generate_reply(conversation, content) + fallback_reply = generate_reply(conversation, content, knowledge_context=knowledge_context) assistant_parts = [fallback_reply] logger.info( "Non-stream fallback reply succeeded", @@ -461,6 +466,118 @@ def build_conversation_title(content: str) -> str: 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 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( diff --git a/tests/test_chat_knowledge_context.py b/tests/test_chat_knowledge_context.py new file mode 100644 index 0000000..a31a0f3 --- /dev/null +++ b/tests/test_chat_knowledge_context.py @@ -0,0 +1,59 @@ +import pytest + +from review_agent.models import KnowledgeBaseDocument +from review_agent.services import build_knowledge_context + + +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 diff --git a/tests/test_file_summary_workflow.py b/tests/test_file_summary_workflow.py index 18feb42..9822751 100644 --- a/tests/test_file_summary_workflow.py +++ b/tests/test_file_summary_workflow.py @@ -201,17 +201,36 @@ def test_stream_message_returns_workflow_meta_when_triggered(settings, django_us 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): + 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, "你好")) + 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): @@ -257,12 +276,15 @@ def test_stream_message_falls_back_to_non_stream_reply_when_stream_breaks(monkey user = django_user_model.objects.create_user(username="owner", password="pass") conversation = Conversation.objects.create(user=user, title="会话") - def broken_stream_reply(conversation, content): + 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: "非流式完整回复") + monkeypatch.setattr( + "review_agent.services.generate_reply", + lambda conversation, content, knowledge_context="": "非流式完整回复", + ) frames = list(stream_message(conversation, "普通问题")) diff --git a/tests/test_llm_streaming.py b/tests/test_llm_streaming.py index dae4f91..c5a4545 100644 --- a/tests/test_llm_streaming.py +++ b/tests/test_llm_streaming.py @@ -3,7 +3,7 @@ from urllib import request import pytest -from review_agent.llm import stream_reply +from review_agent.llm import build_messages, stream_reply from review_agent.models import Conversation @@ -39,3 +39,16 @@ def test_stream_reply_skips_malformed_sse_data(monkeypatch, settings, django_use 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": "孙之烨是谁"} From ef0a9ee13e30906106fa62e3d3d8cf61dd20a90f Mon Sep 17 00:00:00 2001 From: bruce Date: Mon, 8 Jun 2026 21:39:38 +0800 Subject: [PATCH 091/111] =?UTF-8?q?feat(conversations):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=88=A0=E9=99=A4=E5=AF=B9=E8=AF=9D=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E4=BE=A7=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/file_summary/views.py | 8 + review_agent/urls.py | 6 + static/css/login.css | 790 ++++++++++++++++++++++++++++- static/js/app.js | 76 ++- templates/base.html | 2 +- templates/home.html | 26 +- tests/test_file_summary_views.py | 27 + 7 files changed, 918 insertions(+), 17 deletions(-) diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py index 3fe4120..cb701cf 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -148,6 +148,14 @@ def conversation_list(request): ) +@require_http_methods(["DELETE"]) +@login_required +def conversation_detail(request, conversation_id: int): + conversation = _conversation_for_user(request.user, conversation_id) + 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): diff --git a/review_agent/urls.py b/review_agent/urls.py index a2b1d24..dfe648c 100644 --- a/review_agent/urls.py +++ b/review_agent/urls.py @@ -6,6 +6,7 @@ from .file_summary.views import ( attachments, batch_events, batch_status, + conversation_detail, conversation_list, conversation_messages, export_download, @@ -35,6 +36,11 @@ urlpatterns = [ 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, diff --git a/static/css/login.css b/static/css/login.css index fa4ded9..e21ca9b 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -147,7 +147,7 @@ input:focus { gap: 24px; padding: 18px; min-height: 0; - overflow-y: auto; + 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; @@ -259,19 +259,47 @@ input:focus { 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; - gap: 4px; - padding: 14px; + 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); - text-decoration: none; background: rgba(255, 255, 255, 0.82); } @@ -281,7 +309,18 @@ input:focus { 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; } @@ -291,6 +330,41 @@ input:focus { 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); @@ -800,11 +874,13 @@ input:focus { .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-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; } @@ -1422,6 +1498,639 @@ input:focus { 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; @@ -1510,9 +2219,80 @@ input:focus { 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; + } } @keyframes pulse-caret { diff --git a/static/js/app.js b/static/js/app.js index 58e1230..015a1f5 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -26,6 +26,13 @@ return; } + function getCsrfToken() { + if (!composer) { + return ""; + } + return new FormData(composer).get("csrfmiddlewaretoken") || ""; + } + function isMobile() { return window.matchMedia("(max-width: 980px)").matches; } @@ -415,19 +422,60 @@ empty.remove(); } - var item = document.createElement("a"); + var item = document.createElement("div"); item.className = "history-item active"; item.setAttribute("data-conversation-id", conversationId); - item.href = "/?conversation=" + 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; @@ -1167,6 +1215,25 @@ }); } + 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(); @@ -1176,6 +1243,7 @@ bindConditionConfirmForms(); bindRectificationActionButtons(); bindPromptTemplateButtons(); + bindConversationDeleteButtons(); refreshRunningWorkflowCards(); if (chatScroll) { diff --git a/templates/base.html b/templates/base.html index a6064cd..adeff1b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -5,7 +5,7 @@ {% block title %}DEMO-AGENT V2{% endblock %} - + {% block content %}{% endblock %} diff --git a/templates/home.html b/templates/home.html index ef75d33..82d1c7c 100644 --- a/templates/home.html +++ b/templates/home.html @@ -10,8 +10,8 @@ @@ -72,14 +72,26 @@
      {% empty %}

      暂无会话记录

      @@ -350,5 +362,5 @@ {% block scripts %} - + {% endblock %} diff --git a/tests/test_file_summary_views.py b/tests/test_file_summary_views.py index ec0411f..b6b277f 100644 --- a/tests/test_file_summary_views.py +++ b/tests/test_file_summary_views.py @@ -254,6 +254,33 @@ def test_conversation_list_api_returns_owned_conversations_with_attachment_count 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_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="会话") From ccfa43645eca2955f476fbfc8ca8d1357c737d59 Mon Sep 17 00:00:00 2001 From: bruce Date: Mon, 8 Jun 2026 22:25:16 +0800 Subject: [PATCH 092/111] =?UTF-8?q?feat(dashboard):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=B7=A5=E4=BD=9C=E5=8F=B0=E5=B9=B6=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E8=81=8A=E5=A4=A9=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/urls.py | 5 +- review_agent/views.py | 166 +++++++++++++++++- static/css/login.css | 127 ++++++++++++++ templates/attachment_manager.html | 6 +- templates/home.html | 8 +- templates/knowledge_base.html | 6 +- templates/workbench.html | 173 +++++++++++++++++++ tests/test_application_form_fill_frontend.py | 2 +- tests/test_file_summary_frontend.py | 10 +- tests/test_home_dashboard.py | 146 ++++++++++++++++ tests/test_regulatory_frontend.py | 8 +- 11 files changed, 632 insertions(+), 25 deletions(-) create mode 100644 templates/workbench.html create mode 100644 tests/test_home_dashboard.py diff --git a/config/urls.py b/config/urls.py index caf51ba..3de58ba 100644 --- a/config/urls.py +++ b/config/urls.py @@ -2,10 +2,11 @@ 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, knowledge_base_manager, stream_chat, workspace +from review_agent.views import attachment_manager, home_dashboard, knowledge_base_manager, stream_chat, workspace urlpatterns = [ - path("", workspace, name="home"), + 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")), diff --git a/review_agent/views.py b/review_agent/views.py index f629cfb..6297a1d 100644 --- a/review_agent/views.py +++ b/review_agent/views.py @@ -1,9 +1,10 @@ from django.contrib.auth.decorators import login_required -from django.db.models import Count, Q +from django.db.models import Count, Q, Sum import json 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 ( @@ -28,6 +29,29 @@ from .models import KnowledgeBaseDocument from .regulatory_review.services.info_extract import ensure_regulatory_condition_candidates +@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: @@ -39,7 +63,7 @@ def workspace(request: HttpRequest) -> HttpResponse: if action == "new_conversation": conversation = create_conversation(request.user) - return redirect(f"/?conversation={conversation.pk}") + return redirect(f"/chat/?conversation={conversation.pk}") if action == "send_message": content = (request.POST.get("prompt") or "").strip() @@ -47,7 +71,7 @@ def workspace(request: HttpRequest) -> HttpResponse: conversation = create_conversation(request.user) if content: send_message(conversation, content) - return redirect(f"/?conversation={conversation.pk}") + return redirect(f"/chat/?conversation={conversation.pk}") search = (request.GET.get("q") or "").strip() conversations = list_conversations(request.user, search) @@ -325,3 +349,139 @@ def _format_form_fill_label(batch: ApplicationFormFillBatch) -> str: if batch.risk_notes: parts.append(f"提示 {len(batch.risk_notes)}") 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 index e21ca9b..b7fa671 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -1478,6 +1478,116 @@ input:focus { 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); @@ -2293,6 +2403,23 @@ input:focus { 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 { diff --git a/templates/attachment_manager.html b/templates/attachment_manager.html index 5b7fd7c..581eb24 100644 --- a/templates/attachment_manager.html +++ b/templates/attachment_manager.html @@ -9,8 +9,8 @@
      @@ -52,7 +52,7 @@ {% endfor %} {% if selected_conversation %} - 返回对话 + 返回对话 {% endif %}
      diff --git a/templates/home.html b/templates/home.html index 82d1c7c..467b64b 100644 --- a/templates/home.html +++ b/templates/home.html @@ -9,8 +9,8 @@
      -
      + {% csrf_token %} diff --git a/templates/knowledge_base.html b/templates/knowledge_base.html index efc87cd..c899103 100644 --- a/templates/knowledge_base.html +++ b/templates/knowledge_base.html @@ -9,8 +9,8 @@
      {{ knowledge_base.status.label }} - 返回对话 + 返回对话
      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/test_application_form_fill_frontend.py b/tests/test_application_form_fill_frontend.py index 7df21f6..55563f7 100644 --- a/tests/test_application_form_fill_frontend.py +++ b/tests/test_application_form_fill_frontend.py @@ -31,7 +31,7 @@ def test_workspace_renders_application_form_fill_workflow_card(client, django_us ) client.force_login(user) - response = client.get(f"{reverse('home')}?conversation={conversation.pk}") + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") content = response.content.decode("utf-8") assert "AFF-CARD" in content diff --git a/tests/test_file_summary_frontend.py b/tests/test_file_summary_frontend.py index 27e9675..15289ac 100644 --- a/tests/test_file_summary_frontend.py +++ b/tests/test_file_summary_frontend.py @@ -17,7 +17,7 @@ def test_workspace_renders_summary_panel(client, django_user_model): ) client.force_login(user) - response = client.get(f"{reverse('home')}?conversation={conversation.pk}") + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") assert response.status_code == 200 content = response.content.decode("utf-8") @@ -37,7 +37,7 @@ def test_workspace_links_to_attachment_manager(client, django_user_model): conversation = Conversation.objects.create(user=user, title="会话") client.force_login(user) - response = client.get(f"{reverse('home')}?conversation={conversation.pk}") + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") assert response.status_code == 200 content = response.content.decode("utf-8") @@ -85,7 +85,7 @@ def test_attachment_manager_selects_conversation_and_lists_attachments(client, d assert "编辑" in content assert "删除" in content assert "attachment-manager-split" in content - assert reverse("home") + f"?conversation={conversation.pk}" in content + assert reverse("chat") + f"?conversation={conversation.pk}" in content def test_attachment_manager_uses_compact_admin_layout(client, django_user_model): @@ -142,7 +142,7 @@ def test_workspace_renders_workflow_history_as_batch_carousel(client, django_use ) client.force_login(user) - response = client.get(f"{reverse('home')}?conversation={conversation.pk}") + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") assert response.status_code == 200 content = response.content.decode("utf-8") @@ -265,7 +265,7 @@ def test_workspace_tool_buttons_fill_default_prompts(client, django_user_model): conversation = Conversation.objects.create(user=user, title="会话") client.force_login(user) - response = client.get(f"{reverse('home')}?conversation={conversation.pk}") + 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() 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_regulatory_frontend.py b/tests/test_regulatory_frontend.py index de59447..9964434 100644 --- a/tests/test_regulatory_frontend.py +++ b/tests/test_regulatory_frontend.py @@ -44,7 +44,7 @@ def test_workspace_renders_regulatory_workflow_card(client, django_user_model): ) client.force_login(user) - response = client.get(f"{reverse('home')}?conversation={conversation.pk}") + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") content = response.content.decode("utf-8") assert "RR-CARD" in content @@ -97,7 +97,7 @@ def test_workspace_renders_condition_confirmation_form(client, django_user_model ) client.force_login(user) - response = client.get(f"{reverse('home')}?conversation={conversation.pk}") + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") content = response.content.decode("utf-8") assert "适用条件确认" in content @@ -152,7 +152,7 @@ def test_workspace_refreshes_incomplete_condition_confirmation_candidates(client ) client.force_login(user) - response = client.get(f"{reverse('home')}?conversation={conversation.pk}") + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") content = response.content.decode("utf-8") assert "体外诊断试剂" in content @@ -193,7 +193,7 @@ def test_workspace_renders_rectification_actions_and_summaries(client, tmp_path, ) client.force_login(user) - response = client.get(f"{reverse('home')}?conversation={conversation.pk}") + response = client.get(f"{reverse('chat')}?conversation={conversation.pk}") content = response.content.decode("utf-8") assert "data-rectification-action=\"full-review\"" in content From 681cb03eb96c21bb8523df89a9b0e6baf214d4e5 Mon Sep 17 00:00:00 2001 From: bruce Date: Mon, 8 Jun 2026 23:44:50 +0800 Subject: [PATCH 093/111] =?UTF-8?q?chore(config):=20=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E6=BC=94=E7=A4=BA=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 4 +++- config/settings.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.env b/.env index 2167011..e83ae16 100644 --- a/.env +++ b/.env @@ -6,7 +6,9 @@ DJANGO_ALLOWED_HOSTS=* LLM_PROVIDER=openai_compatible LLM_API_KEY=sk-pgvkjondmmrlyxmrfhotgpuirgbtgzrpjpweorhwruflxmxw LLM_BASE_URL=https://api.siliconflow.cn/v1 -LLM_MODEL=Qwen/Qwen2.5-7B-Instruct +LLM_MODEL=deepseek-ai/DeepSeek-V4-Pro +SILICONFLOW_EMBEDDING_MODEL=Qwen/Qwen3-Embedding-8B +SILICONFLOW_EMBEDDING_DIMENSIONS=4096 # SiliconFlow embedding model for RAG EMBEDDING_API_KEY=sk-pgvkjondmmrlyxmrfhotgpuirgbtgzrpjpweorhwruflxmxw diff --git a/config/settings.py b/config/settings.py index c996115..e971017 100644 --- a/config/settings.py +++ b/config/settings.py @@ -119,7 +119,7 @@ REGULATORY_LLM_REVIEW_MAX_ATTEMPTS = int(os.environ.get("REGULATORY_LLM_REVIEW_M 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", "") +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", From d8cd95e590227bfab81222370f4e404331e8a9aa Mon Sep 17 00:00:00 2001 From: bruce Date: Mon, 8 Jun 2026 23:45:06 +0800 Subject: [PATCH 094/111] =?UTF-8?q?docs(report):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E6=B1=87=E6=8A=A5=E6=9D=90=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/7.汇报材料/架构搭建思路汇报稿.md | 474 ++++++++++++-------------- 1 file changed, 226 insertions(+), 248 deletions(-) diff --git a/docs/7.汇报材料/架构搭建思路汇报稿.md b/docs/7.汇报材料/架构搭建思路汇报稿.md index 35040d5..9713145 100644 --- a/docs/7.汇报材料/架构搭建思路汇报稿.md +++ b/docs/7.汇报材料/架构搭建思路汇报稿.md @@ -1,115 +1,175 @@ # 架构搭建思路汇报稿(基于 Demo 版) -## 一、汇报开场 +## 一、设计路径:先锁规格,再实现代码 各位老师好,我本次 Demo 搭建的是一个面向体外诊断试剂注册资料准备与审核的智能体原型。 -这个 Demo 的目标不是简单做文件上传、文件解析或问答,而是把注册资料审核中几个高频、耗时、容易出错的环节串成一个可追溯的智能工作流,包括文件目录汇总、法规完整性核查、产品关键信息提取、申报表自动填充,以及异常风险预警。 +这次开发没有直接从代码开始,而是采用“文档先行、规格锁定、再实现代码”的路径。原因是注册资料审核不是一个简单问答场景,它涉及文件解析、法规规则、RAG 依据、工作流状态、导出文件、人工确认和整改闭环。如果一开始就写代码,很容易出现功能能跑但边界不清、结果不可追溯、后续难维护的问题。 -从整体定位上看,它更像是一个“注册资料审核助手”:用户上传一批申报资料后,系统能够先把资料包结构化,再对照法规规则做核查,之后输出风险清单和整改建议,并把抽取到的产品信息继续复用到申报模板填表中。 - -## 二、Demo 运行结果展示 - -本次 Demo 目前可以展示四类核心运行结果。 - -### 1. 文件目录汇总表 - -用户上传注册资料文件夹、散装文件或压缩包后,系统会自动完成附件固化、压缩包解压、文件扫描和页数统计。 - -最终系统会生成 Markdown 汇总报告和 Excel 文件明细表,主要字段包括: - -| 字段 | 说明 | -| --- | --- | -| 序号 | 文件在批次中的顺序 | -| 目录层级 | 文件所在的相对目录 | -| 文件名 | 原始文件名 | -| 类型 | PDF、Word、Excel、PPT 等文件类型 | -| 页数 | PDF 页数、Word 页数、PPT 幻灯片数或 Excel 工作表数 | -| 路径 | 文件在批次工作目录中的相对路径 | -| 状态 | success、failed、unsupported、uncertain 等 | -| 重试次数 | 页数统计失败时的重试记录 | -| 异常说明 | 不支持、不可确定或解析失败的原因 | - -这个结果解决的是资料包进入系统后的第一步问题:先把杂乱的文件夹变成结构化的文件清单。 - -### 2. 法规完整性报告 - -在文件汇总结果基础上,系统会调用法规核查工作流,对照 NMPA 体外诊断试剂注册申报资料要求进行完整性检查。 - -Demo 中使用 `review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml` 作为结构化规则文件。规则文件中配置了附件 4 的资料要求,例如监管信息、综述资料、非临床资料、临床评价资料、说明书和标签样稿、质量管理体系文件等。 - -系统会检查是否缺少关键资料,例如: - -| 检查对象 | 风险示例 | -| --- | --- | -| 注册申请表 | 缺失时生成阻断项或高风险 | -| 符合性声明 | 缺失时生成阻断项 | -| 产品技术要求 | 缺失时生成阻断项 | -| 注册检验报告 | 缺失时生成阻断项 | -| 产品说明书 | 缺失或章节不完整时生成高风险 | -| 标签样稿 | 缺失时生成高风险 | -| 临床评价资料 | 按适用条件生成条件性风险 | -| 质量管理体系文件 | 缺失时生成高风险 | - -最终输出包括 Markdown 法规核查报告、Excel 问题清单和 JSON 结构化结果包。 - -### 3. 信息提取对照表 - -系统会从说明书、产品技术要求、注册检验报告、申请表等文件中抽取产品关键信息。 - -当前 Demo 中重点抽取的字段包括: - -| 字段 | 用途 | -| --- | --- | -| 产品名称 | 用于一致性核查和申报表填充 | -| 型号规格 | 用于跨文件比对 | -| 预期用途 | 用于法规适用条件和模板填充 | -| 管理类别 | 用于法规判断 | -| 分类编码 | 用于注册资料核对 | -| 注册类型 | 用于模板选择和法规规则裁剪 | -| 临床评价路径 | 用于临床资料适用性判断 | - -每个抽取结果都会保留来源文件、来源角色、证据片段、抽取方式和置信度。这样后续生成的填表内容不是黑盒结果,而是能够回溯到原始文件。 - -### 4. 异常预警列表 - -系统会把完整性缺失、章节异常、字段冲突、文本抽取失败、页数不可确定、通知失败等问题统一沉淀为风险项。 - -风险等级目前分为: - -| 风险等级 | 含义 | -| --- | --- | -| 阻断项 | 影响注册资料完整性或关键合规判断,需要优先整改 | -| 高风险 | 可能影响审评,需要重点关注 | -| 中风险 | 建议整改或补充说明 | -| 低风险 | 轻微问题或格式提示 | -| 提示项 | 不直接影响结论,但建议人工确认 | - -例如,如果系统发现不同文件中的“产品名称”或“型号规格”不一致,会生成一致性风险;如果缺少注册检验报告,会生成阻断项,并给出补充注册检验报告的整改建议。 - -## 三、智能体整体工作流 - -结合当前 Demo 的实现,智能体整体工作流可以概括为: +所以整体设计路径分为四步: ```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 文件上传 -> 附件固化 @@ -117,17 +177,17 @@ Demo 中使用 `review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.ya -> 文件扫描 -> 页数统计 -> 产品名识别 --> 报告输出 +-> Markdown/Excel 报告输出 ``` -这个链路的核心作用是把原始资料包转换成结构化数据。系统会生成 `FileSummaryBatch` 和 `FileSummaryItem`,后续法规核查和自动填表都复用这套文件清单,不再重复扫描文件。 +这个链路负责把原始资料包转换成结构化文件清单。系统会生成 `FileSummaryBatch` 和 `FileSummaryItem`,后续法规核查和自动填表都复用这套文件清单,不再重复扫描资料。 + +输出字段包括序号、目录层级、文件名、文件类型、页数、相对路径、统计状态、重试次数和异常说明。 ### 2. 法规核查链路 对应模块:`review_agent/regulatory_review` -主要流程为: - ```text 准备资料 -> 适用条件确认 @@ -136,20 +196,20 @@ Demo 中使用 `review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.ya -> 文本抽取 -> 章节核查 -> 一致性核查 +-> RAG 法规依据补充 -> 风险评估 -> 报告输出 +-> 整改复核 ``` -这条链路的核心设计原则是:规则优先,RAG 补依据,LLM 做辅助。 +这条链路使用 `review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml` 作为结构化规则文件。规则中配置了附件 4 的资料要求,包括监管信息、综述资料、非临床资料、临床评价资料、说明书和标签样稿、质量管理体系文件等。 -也就是说,法规结论不直接交给大模型自由判断,而是优先由结构化规则文件决定;RAG 负责检索法规依据和原文片段;LLM 主要用于低置信度字段抽取、自然语言条件解析和结果复核。 +系统会检查是否缺少关键资料,例如注册申请表、符合性声明、产品技术要求、注册检验报告、说明书、标签样稿、临床评价资料和质量管理体系文件。缺失项会转成 `RegulatoryIssue`,并按阻断项、高风险、中风险、低风险和提示项分级。 ### 3. 自动填表链路 对应模块:`review_agent/application_form_fill` -主要流程为: - ```text 准备资料 -> 模板选择 @@ -161,173 +221,91 @@ Demo 中使用 `review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.ya -> 结果通知 ``` -这条链路会复用前面抽取到的产品信息,自动选择申报模板,并将字段填入 Word 模板。对于冲突字段,Demo 中采用“说明书优先”的策略,同时在结果中保留冲突摘要和来源追溯。 +这条链路会复用前面抽取到的产品信息,自动选择申报模板,并将字段填入 Word 模板。对于冲突字段,Demo 中采用明确的归并策略,同时在结果中保留冲突摘要和来源追溯。 -## 四、Demo 实际调用的关键工具和库 +## 八、页面和数据工作台 -本 Demo 在工具选型上以轻量、可本地运行、可解释、便于测试为原则。 +前端目前包括四个主要页面: -### 1. 文件解析类工具 - -| 工具/库 | Demo 中的用途 | 选用理由 | +| 页面 | URL | 作用 | | --- | --- | --- | -| `pypdf` | PDF 页数统计和文本抽取 | 轻量、安装简单,适合 Demo 阶段快速处理 PDF | -| `python-docx` | DOCX 文本读取、Word 模板填充 | 可读取段落和表格,也能写入 Word 模板 | -| `python-pptx` | PPTX 幻灯片数量统计和文本读取 | 适合统计幻灯片数量和抽取文本 | -| `openpyxl` | XLSX 工作表统计、Excel 报告导出 | 同时支持读取和生成 Excel | -| `xlrd` | 旧版 XLS 文件读取 | 补充对历史 Excel 格式的支持 | -| `olefile` | 判断老 Office 文件 OLE 结构 | 用于 doc、xls、ppt 等老格式的兜底识别 | -| `py7zr` | 7z 压缩包解压 | 支持常见资料包压缩格式 | -| Python `zipfile` | ZIP 压缩包解压 | 标准库能力,无额外依赖 | +| 首页工作台 | `/` | 展示对话、附件、知识库、批次状态和最近处理记录 | +| 审核智能体 | `/chat/` | 对话、上传附件、启动工作流、查看节点进度 | +| 知识库管理 | `/knowledge-base/` | 管理用户上传知识库、查看内置法规材料和索引状态 | +| 附件管理 | `/attachments/` | 管理不同对话下的上传附件、版本、启用状态和下载 | -Demo 中没有选择重型 OCR 或复杂版式引擎,是因为当前阶段重点是打通审核链路和规则闭环。对于扫描件、图片 PDF、复杂版式 PDF,后续可以再接入 OCR 和更强的版式解析能力。 - -### 2. 规则和正则 - -系统使用 YAML 维护法规规则,例如 `nmpa_ivd_registration_v1.yaml`。每条规则包含规则编码、附件 4 编码、标题、资料类型、风险等级、匹配关键词、整改建议和 RAG 检索查询词。 - -正则表达式用于抽取结构化字段,例如: +首页工作台重点不是营销展示,而是运行态数据,包括: ```text -产品名称:xxx -型号规格:xxx -预期用途:xxx -管理类别:xxx -分类编码:xxx +对话总数 +附件总数 +知识库材料数 +执行中批次 +已处理批次 +成功批次 +等待确认批次 +失败批次 +最近处理记录 ``` -选用规则和正则的原因是:这类注册资料中有大量固定标题和固定字段,使用确定性规则可以提高可解释性,也便于定位问题来源。 +知识库材料中同时统计用户管理文档和内置法规材料,避免把“知识库”误解成只包含用户上传文件。 -### 3. RAG 和向量检索 +## 九、过程留痕和可追溯设计 -Demo 使用 ChromaDB 构建本地法规 RAG 索引。法规原文材料会被切分为文本块,并保存来源文件、chunk 编号等元数据。 - -向量 embedding 支持两种模式: - -| 模式 | 用途 | -| --- | --- | -| SiliconFlow embedding | 用于真实语义检索 | -| deterministic/local embedding | 用于测试和 dry run | - -RAG 在系统中的定位不是直接判断合规,而是为风险问题补充法规依据。例如完整性规则已经判断“缺少注册检验报告”,RAG 再检索相关法规条款,输出来源文件和依据片段,增强报告的可解释性。 - -### 4. LLM 调用 - -LLM 在 Demo 中主要承担辅助角色,包括: - -| 场景 | LLM 作用 | -| --- | --- | -| 自然语言适用条件解析 | 将用户输入转换为结构化字段 | -| 低置信度字段抽取 | 正则抽取不足时补充结构化 JSON | -| 工作流结果复核 | 对中间结果做总结和校验 | -| 整改建议润色 | 在规则模板基础上优化表达 | - -风险等级、法规结论和完整性判断不直接交给 LLM 决定,而是由规则引擎和风险评估服务控制。 - -### 5. 工作流和状态管理 - -系统使用 Django ORM 保存批次、节点、事件和导出文件。 - -关键模型包括: - -| 模型 | 作用 | -| --- | --- | -| `FileSummaryBatch` | 文件汇总批次 | -| `FileSummaryItem` | 文件明细 | -| `RegulatoryReviewBatch` | 法规核查批次 | -| `RegulatoryIssue` | 法规问题和风险项 | -| `RegulatoryArtifact` | 法规核查过程产物 | -| `ApplicationFormFillBatch` | 自动填表批次 | -| `WorkflowNodeRun` | 工作流节点状态 | -| `WorkflowEvent` | SSE 事件和进度记录 | -| `ExportedSummaryFile` | Markdown、Excel、JSON、Word 等导出文件 | - -前端通过 SSE 事件实时展示工作流卡片状态,使用户能够看到每个节点是否正在执行、是否成功、是否等待确认或失败。 - -## 五、难点规则处理方式 - -### 1. 文件完整性检测 - -文件完整性检测的难点在于:注册资料不是固定文件名,企业可能用不同命名方式组织材料。 - -Demo 的处理方式是使用多层匹配: - -```text -规则要求项 --> 文件名关键词匹配 --> 相对路径匹配 --> 目录层级匹配 --> 必要时结合首页文本和字段候选 -``` - -例如规则中要求“注册检验报告”,系统不仅查找文件名中是否包含“注册检验报告”,也会查找路径和目录中是否包含“检验报告”“检测报告”等别名。 - -如果没有匹配到文件,系统会生成 `Finding`,再由风险评估服务转换为 `RegulatoryIssue`。这样完整性问题既能被结构化记录,也能进入最终风险报告。 - -### 2. 信息一致性核查 - -一致性核查的难点在于:同一个字段可能散落在说明书、注册检验报告、产品技术要求、申请表等多个文件中。 - -Demo 的处理方式是: - -```text -文本抽取 --> 字段正则识别 --> 同字段归并 --> 不同取值比对 --> 生成一致性风险 -``` - -例如系统会从多个文件中抽取“产品名称”“型号规格”“预期用途”等字段。如果同一字段出现多个不同值,系统会生成高风险问题,并在证据中记录每个取值对应的来源文件。 - -这类结果可以直接辅助人工审核人员定位冲突来源。 - -### 3. 法规条款匹配 - -法规条款匹配的难点在于:法规原文长、条款多,直接让大模型判断容易不稳定,纯规则又缺少解释能力。 - -Demo 采用“双层法规能力”: - -| 层级 | 职责 | -| --- | --- | -| 结构化规则库 | 负责判断应有哪些文件、哪些章节、哪些字段,以及风险等级 | -| RAG 法规依据索引 | 负责检索法规原文片段,补充依据说明 | - -这种设计的好处是:判断逻辑稳定,报告解释充分,后续规则也可以由法规人员维护。 - -### 4. 过程留痕和可追溯 - -审核类系统不能只输出一个结论,还必须说明结论从哪里来。 - -Demo 中对关键过程都做了留痕: +审核类系统不能只输出一个结论,还必须说明结论从哪里来。因此 Demo 对关键过程都做了结构化留痕。 | 过程 | 留痕内容 | | --- | --- | -| 文件汇总 | 文件路径、页数、统计状态、异常说明 | -| 文本抽取 | 文本 hash、首页文本、章节候选、字段候选 | -| 完整性核查 | 规则编码、匹配关键词、命中文件或缺失证据 | -| 一致性核查 | 字段值、来源文件、冲突取值 | -| RAG 检索 | 法规来源、片段文本、检索分数 | -| 报告导出 | Markdown、Excel、JSON 结果包 | -| 自动填表 | 字段来源、冲突摘要、追溯清单 | +| 对话 | 用户消息、助手消息、会话标题、更新时间 | +| 附件 | 原始文件名、版本号、启用状态、存储路径、文件大小 | +| 文件汇总 | 批次号、文件明细、页数、统计状态、异常说明 | +| 工作流节点 | 节点编码、节点名称、进度、状态、错误信息 | +| 法规核查 | 规则编码、缺失项、风险等级、证据、整改建议 | +| 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 已经完成了文件汇总、法规完整性核查、信息抽取、风险预警、报告导出和自动填表主链路。后续如果继续增强,可以重点补充 OCR、扫描件识别、复杂 PDF 版式解析、规则后台维护、人工确认界面、飞书真实消息闭环,以及更完整的多智能体编排能力。 +当前 Demo 已经完成了首页工作台、审核智能体对话、附件管理、知识库管理、文件汇总、法规核查、RAG 依据检索、风险预警、报告导出和自动填表主链路。后续如果继续增强,可以重点补充 OCR、扫描件识别、复杂 PDF 版式解析、规则后台维护、人工确认界面、飞书真实消息闭环,以及更完整的多智能体编排能力。 最终希望这个智能体能够从一个 Demo 原型,逐步演进为注册资料准备和审核过程中的智能协作平台。 From 2b5093040d5cc060f360aa46bd94cf8fcb147330 Mon Sep 17 00:00:00 2001 From: bruce Date: Mon, 8 Jun 2026 23:45:34 +0800 Subject: [PATCH 095/111] =?UTF-8?q?fix(kb):=20=E5=AE=8C=E5=96=84=E7=9F=A5?= =?UTF-8?q?=E8=AF=86=E5=BA=93=E5=85=A5=E5=BA=93=E5=92=8C=E9=87=8D=E5=BB=BA?= =?UTF-8?q?=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/knowledge_base.py | 10 +- .../commands/regulatory_rag_build.py | 2 +- .../regulatory_review/services/rag_index.py | 46 +++++- review_agent/urls.py | 6 + review_agent/views.py | 23 +++ static/js/knowledge_base.js | 66 ++++++++ templates/knowledge_base.html | 12 +- tests/test_knowledge_base.py | 58 +++++++ tests/test_regulatory_rag.py | 141 ++++++++++++++++++ 9 files changed, 355 insertions(+), 9 deletions(-) diff --git a/review_agent/knowledge_base.py b/review_agent/knowledge_base.py index 12edff7..79f3aba 100644 --- a/review_agent/knowledge_base.py +++ b/review_agent/knowledge_base.py @@ -10,8 +10,8 @@ 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 DeterministicEmbeddingProvider -from review_agent.regulatory_review.services.rag_index import chunk_text, extract_text_from_path +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 @@ -78,6 +78,8 @@ def list_source_documents(source_dir: Path) -> list[dict[str, Any]]: 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( { @@ -101,7 +103,7 @@ def search_knowledge_base(query: str, *, n_results: int = 3) -> dict[str, Any]: try: results = retrieve_citations( normalized, - embedding_provider=DeterministicEmbeddingProvider(), + embedding_provider=get_embedding_provider(), n_results=n_results, ) except RagIndexUnavailable as exc: @@ -210,7 +212,7 @@ def index_managed_document(document: KnowledgeBaseDocument) -> int: return 0 collection = _load_chroma_collection() texts = [chunk.text for chunk in chunks] - embeddings = DeterministicEmbeddingProvider()(texts) + embeddings = get_embedding_provider()(texts) ids = [ hashlib.sha256(f"managed:{document.pk}:{chunk.metadata['chunk_index']}".encode("utf-8")).hexdigest() for chunk in chunks diff --git a/review_agent/management/commands/regulatory_rag_build.py b/review_agent/management/commands/regulatory_rag_build.py index b8be556..c2263aa 100644 --- a/review_agent/management/commands/regulatory_rag_build.py +++ b/review_agent/management/commands/regulatory_rag_build.py @@ -23,7 +23,7 @@ class Command(BaseCommand): raise CommandError(f"法规材料目录不存在:{source_dir}") try: provider = get_embedding_provider(options["provider"]) - count = build_chroma_index(source_dir=source_dir, embedding_provider=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( diff --git a/review_agent/regulatory_review/services/rag_index.py b/review_agent/regulatory_review/services/rag_index.py index be80cf8..3e58826 100644 --- a/review_agent/regulatory_review/services/rag_index.py +++ b/review_agent/regulatory_review/services/rag_index.py @@ -23,6 +23,8 @@ from .rag_embedding import EmbeddingFunction logger = logging.getLogger("review_agent.regulatory_review.rag_index") +EXCLUDED_SOURCE_KEYWORDS = ("模拟题二", "试剂盒临床注册文件准备与审核Agent") + @dataclass(frozen=True) class TextChunk: @@ -227,6 +229,8 @@ def collect_source_chunks(source_dir: Path) -> 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: @@ -238,6 +242,11 @@ def collect_source_chunks(source_dir: Path) -> list[TextChunk]: 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 @@ -249,6 +258,7 @@ def build_chroma_index( embedding_provider: EmbeddingFunction, persist_path: Path | None = None, collection_name: str | None = None, + reset: bool = False, ) -> int: try: import chromadb @@ -259,7 +269,22 @@ def build_chroma_index( collection_name = collection_name or settings.REGULATORY_RAG_COLLECTION persist_path.mkdir(parents=True, exist_ok=True) chunks = collect_source_chunks(source_dir) - client = chromadb.PersistentClient(path=str(persist_path)) + 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 @@ -276,3 +301,22 @@ def build_chroma_index( 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/urls.py b/review_agent/urls.py index dfe648c..4d46250 100644 --- a/review_agent/urls.py +++ b/review_agent/urls.py @@ -25,6 +25,7 @@ from .views import ( knowledge_base_document_detail, knowledge_base_document_index, knowledge_base_documents, + knowledge_base_rebuild_index, knowledge_base_search, knowledge_base_status, ) @@ -121,6 +122,11 @@ urlpatterns = [ 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, diff --git a/review_agent/views.py b/review_agent/views.py index 6297a1d..2933923 100644 --- a/review_agent/views.py +++ b/review_agent/views.py @@ -1,6 +1,8 @@ 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 @@ -27,6 +29,9 @@ from .knowledge_base import ( ) 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 @@ -151,6 +156,24 @@ 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: diff --git a/static/js/knowledge_base.js b/static/js/knowledge_base.js index dd6b9d0..cb756db 100644 --- a/static/js/knowledge_base.js +++ b/static/js/knowledge_base.js @@ -15,6 +15,8 @@ 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) { @@ -68,6 +70,17 @@ 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; @@ -196,6 +209,59 @@ }); } + 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(); diff --git a/templates/knowledge_base.html b/templates/knowledge_base.html index c899103..aa4039c 100644 --- a/templates/knowledge_base.html +++ b/templates/knowledge_base.html @@ -32,6 +32,7 @@ class="knowledge-page" data-document-url="{% url 'knowledge_base_document_list' %}" data-search-url="{% url 'knowledge_base_search' %}" + data-rebuild-url="{% url 'knowledge_base_rebuild_index' %}" >
      @@ -96,9 +97,10 @@

      {{ knowledge_base.status.message }}

      +

      - +
      @@ -182,6 +184,7 @@ 类型 大小 索引 + 操作 @@ -192,10 +195,13 @@ {{ source.suffix }} {{ source.size }} bytes {{ source.indexed_label }} + + + {% empty %} - 暂无法规材料 + 暂无法规材料 {% endfor %} @@ -209,5 +215,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/tests/test_knowledge_base.py b/tests/test_knowledge_base.py index 936d822..21df46f 100644 --- a/tests/test_knowledge_base.py +++ b/tests/test_knowledge_base.py @@ -3,6 +3,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from review_agent.knowledge_base import build_knowledge_base_context, delete_document, search_knowledge_base +from review_agent.views import rebuild_knowledge_base_index from review_agent.models import KnowledgeBaseDocument @@ -16,6 +17,7 @@ def test_knowledge_base_context_reports_rule_and_sources(): 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): @@ -36,6 +38,11 @@ def test_knowledge_base_page_renders_for_user(client, django_user_model): 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("") @@ -103,6 +157,8 @@ def test_knowledge_base_search_api_returns_payload(client, django_user_model): 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) @@ -199,6 +255,8 @@ def test_knowledge_base_document_api_is_scoped_to_owner(client, django_user_mode 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" diff --git a/tests/test_regulatory_rag.py b/tests/test_regulatory_rag.py index 356ffc6..79930c4 100644 --- a/tests/test_regulatory_rag.py +++ b/tests/test_regulatory_rag.py @@ -1,3 +1,5 @@ +import sys + import pytest from review_agent.regulatory_review.services.rag_citation import ( @@ -7,6 +9,7 @@ from review_agent.regulatory_review.services.rag_citation import ( 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): @@ -86,3 +89,141 @@ def test_collect_source_chunks_requires_attachment4_extraction(monkeypatch, tmp_ 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() From 18548eb78fcc6db524e63b05c0456ac899b46f0a Mon Sep 17 00:00:00 2001 From: bruce Date: Tue, 9 Jun 2026 08:22:45 +0800 Subject: [PATCH 096/111] =?UTF-8?q?fix(file-summary):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=AF=B9=E8=AF=9D=E6=97=B6=E5=8F=97=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=E6=89=B9=E6=AC=A1=E9=98=BB=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/file_summary/views.py | 15 +++++++++++-- tests/test_file_summary_views.py | 34 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py index cb701cf..b71ed75 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -1,4 +1,5 @@ from django.contrib.auth.decorators import login_required +from django.db import transaction from django.db.models import Count, Q import json import logging @@ -7,7 +8,14 @@ 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 +from review_agent.models import ( + ApplicationFormFillBatch, + Conversation, + ExportedSummaryFile, + FileAttachment, + Message, + RegulatoryReviewBatch, +) from review_agent.models import FileSummaryBatch, WorkflowEvent from review_agent.notifications.presenter import serialize_notification_records from .events import serialize_event @@ -152,7 +160,10 @@ def conversation_list(request): @login_required def conversation_detail(request, conversation_id: int): conversation = _conversation_for_user(request.user, conversation_id) - conversation.delete() + 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}) diff --git a/tests/test_file_summary_views.py b/tests/test_file_summary_views.py index b6b277f..c4a8a83 100644 --- a/tests/test_file_summary_views.py +++ b/tests/test_file_summary_views.py @@ -10,6 +10,7 @@ from review_agent.models import ( FileAttachment, FileSummaryBatch, Message, + RegulatoryReviewBatch, WorkflowNodeRun, ) @@ -269,6 +270,39 @@ def test_conversation_delete_api_removes_owned_conversation(client, django_user_ 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") From 42187bf8e96c0621991f3e58da0c920c69d56611 Mon Sep 17 00:00:00 2001 From: bruce Date: Tue, 9 Jun 2026 08:22:57 +0800 Subject: [PATCH 097/111] =?UTF-8?q?fix(knowledge-base):=20=E5=81=9C?= =?UTF-8?q?=E7=94=A8=E6=96=87=E6=A1=A3=E6=97=B6=E5=90=8C=E6=AD=A5=E6=B8=85?= =?UTF-8?q?=E7=90=86=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/knowledge_base.py | 20 +++++++++- tests/test_knowledge_base.py | 69 +++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/review_agent/knowledge_base.py b/review_agent/knowledge_base.py index 79f3aba..de2714b 100644 --- a/review_agent/knowledge_base.py +++ b/review_agent/knowledge_base.py @@ -153,6 +153,7 @@ def create_document_from_upload( 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") @@ -160,12 +161,21 @@ def update_document(document: KnowledgeBaseDocument, payload: dict[str, Any]) -> document.description = str(payload.get("description") or "").strip() update_fields.append("description") if "is_active" in payload: - document.is_active = bool(payload.get("is_active")) - document.status = KnowledgeBaseDocument.Status.ACTIVE if document.is_active else KnowledgeBaseDocument.Status.DISABLED + 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 @@ -198,6 +208,12 @@ def serialize_document(document: KnowledgeBaseDocument) -> dict[str, Any]: 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 diff --git a/tests/test_knowledge_base.py b/tests/test_knowledge_base.py index 21df46f..dec1515 100644 --- a/tests/test_knowledge_base.py +++ b/tests/test_knowledge_base.py @@ -2,7 +2,13 @@ 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, search_knowledge_base +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 @@ -232,6 +238,67 @@ def test_delete_document_removes_managed_chunks_from_index(monkeypatch, django_u 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") From 26e675e5d3db89b78168dedfdf34bb053e27567f Mon Sep 17 00:00:00 2001 From: bruce Date: Tue, 9 Jun 2026 08:23:08 +0800 Subject: [PATCH 098/111] =?UTF-8?q?fix(chat):=20=E6=8B=A6=E6=88=AA?= =?UTF-8?q?=E6=97=A0=E4=BE=9D=E6=8D=AE=E7=9A=84=E9=9D=9E=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/services.py | 107 +++++++++++++++++++++++++-- tests/test_chat_knowledge_context.py | 66 ++++++++++++++++- tests/test_file_summary_workflow.py | 2 +- 3 files changed, 168 insertions(+), 7 deletions(-) diff --git a/review_agent/services.py b/review_agent/services.py index 45b1d74..0bd9c7e 100644 --- a/review_agent/services.py +++ b/review_agent/services.py @@ -108,10 +108,13 @@ def send_message(conversation: Conversation, content: str) -> tuple[Message, Mes user_message = append_user_message(conversation, content) knowledge_context = build_knowledge_context(content) - try: - reply_content = generate_reply(conversation, content, knowledge_context=knowledge_context) - except (LLMConfigurationError, LLMRequestError) as exc: - reply_content = f"模型调用失败:{exc}" + 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) @@ -127,6 +130,31 @@ def stream_message(conversation: Conversation, content: str): 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", @@ -395,7 +423,6 @@ def stream_message(conversation: Conversation, content: str): stream_failed = False stream_error = "" - knowledge_context = build_knowledge_context(content) try: for chunk in stream_reply(conversation, content, knowledge_context=knowledge_context): assistant_parts.append(chunk) @@ -497,6 +524,76 @@ def build_knowledge_context(content: str, *, n_results: int = 5) -> str: 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: diff --git a/tests/test_chat_knowledge_context.py b/tests/test_chat_knowledge_context.py index a31a0f3..af12f02 100644 --- a/tests/test_chat_knowledge_context.py +++ b/tests/test_chat_knowledge_context.py @@ -1,7 +1,7 @@ import pytest from review_agent.models import KnowledgeBaseDocument -from review_agent.services import build_knowledge_context +from review_agent.services import build_knowledge_context, send_message, stream_message pytestmark = pytest.mark.django_db @@ -57,3 +57,67 @@ def test_build_knowledge_context_uses_full_document_when_name_matches(settings, 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_file_summary_workflow.py b/tests/test_file_summary_workflow.py index 9822751..8db3ac1 100644 --- a/tests/test_file_summary_workflow.py +++ b/tests/test_file_summary_workflow.py @@ -286,7 +286,7 @@ def test_stream_message_falls_back_to_non_stream_reply_when_stream_breaks(monkey lambda conversation, content, knowledge_context="": "非流式完整回复", ) - frames = list(stream_message(conversation, "普通问题")) + frames = list(stream_message(conversation, "注册检验报告审核要点有哪些")) joined = "".join(frames) assert "已生成部分内容" in joined From 8548b6d2b48236fbc9d446798c9284570244e5bc Mon Sep 17 00:00:00 2001 From: bruce Date: Wed, 10 Jun 2026 08:41:50 +0800 Subject: [PATCH 099/111] =?UTF-8?q?docs:=E5=8E=9F=E5=A7=8B=E6=9D=90?= =?UTF-8?q?=E6=96=99=E5=86=85=E5=AE=B9=E8=A1=A5=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/0.原始材料/目标产品说明书.docx | Bin 0 -> 51977 bytes docs/0.原始材料/第1章 监管信息.rar | Bin 0 -> 131623 bytes .../第1章 监管信息/CH1.11.1 符合标准的清单.docx | Bin 0 -> 16779 bytes .../第1章 监管信息/CH1.11.5 真实性声明.docx | Bin 0 -> 22374 bytes .../第1章 监管信息/CH1.11.6 符合性声明.docx | Bin 0 -> 16544 bytes .../第1章 监管信息/CH1.2 监管信息目录.docx | Bin 0 -> 21828 bytes .../第1章 监管信息/CH1.4 申请表.docx | Bin 0 -> 49840 bytes .../第1章 监管信息/CH1.5 产品列表.docx | Bin 0 -> 32311 bytes .../第1章 监管信息/CH1.9 产品申报前沟通的说明.doc | Bin 0 -> 14336 bytes 9 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/0.原始材料/目标产品说明书.docx create mode 100644 docs/0.原始材料/第1章 监管信息.rar create mode 100644 docs/0.原始材料/第1章 监管信息/CH1.11.1 符合标准的清单.docx create mode 100644 docs/0.原始材料/第1章 监管信息/CH1.11.5 真实性声明.docx create mode 100644 docs/0.原始材料/第1章 监管信息/CH1.11.6 符合性声明.docx create mode 100644 docs/0.原始材料/第1章 监管信息/CH1.2 监管信息目录.docx create mode 100644 docs/0.原始材料/第1章 监管信息/CH1.4 申请表.docx create mode 100644 docs/0.原始材料/第1章 监管信息/CH1.5 产品列表.docx create mode 100644 docs/0.原始材料/第1章 监管信息/CH1.9 产品申报前沟通的说明.doc diff --git a/docs/0.原始材料/目标产品说明书.docx b/docs/0.原始材料/目标产品说明书.docx new file mode 100644 index 0000000000000000000000000000000000000000..56ab2b1832a1cabf72f01cd61527a731e8589707 GIT binary patch literal 51977 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

      a^y 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 literal 0 HcmV?d00001 diff --git a/docs/0.原始材料/第1章 监管信息.rar b/docs/0.原始材料/第1章 监管信息.rar new file mode 100644 index 0000000000000000000000000000000000000000..266bbd88b1d114a744ca45d71b5e58603d5e60f3 GIT binary patch literal 131623 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/0.原始材料/第1章 监管信息/CH1.11.1 符合标准的清单.docx b/docs/0.原始材料/第1章 监管信息/CH1.11.1 符合标准的清单.docx new file mode 100644 index 0000000000000000000000000000000000000000..a48c40bf3a9ee63e4cd0382e30fc44b2504d678c GIT binary patch literal 16779 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

      @@ -241,6 +246,7 @@ data-status-url-template="/api/review-agent/file-summary/__batch_id__/status/" data-regulatory-status-url-template="/api/review-agent/regulatory-review/__batch_id__/status/" data-application-form-fill-status-url-template="/api/review-agent/application-form-fill/__batch_id__/status/" + data-regulatory-info-package-status-url-template="/api/review-agent/regulatory-info-package/__batch_id__/status/" data-events-url-template="/api/review-agent/file-summary/__batch_id__/events/" >
      From 6d4b519f83186eead4970848cfa67b482936809f Mon Sep 17 00:00:00 2001 From: bruce Date: Wed, 10 Jun 2026 19:50:22 +0800 Subject: [PATCH 106/111] =?UTF-8?q?test(regulatory-info-package):=20?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E6=9D=90=E6=96=99=E5=8C=85=E4=B8=BB=E9=93=BE?= =?UTF-8?q?=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...t_regulatory_info_package_field_extract.py | 36 +++++ ...est_regulatory_info_package_field_merge.py | 24 +++ .../test_regulatory_info_package_frontend.py | 45 ++++++ ...st_regulatory_info_package_input_select.py | 48 ++++++ ...latory_info_package_instruction_extract.py | 16 ++ ...test_regulatory_info_package_legacy_doc.py | 9 ++ tests/test_regulatory_info_package_models.py | 109 ++++++++++++++ ...st_regulatory_info_package_notification.py | 17 +++ ...egulatory_info_package_package_generate.py | 31 ++++ tests/test_regulatory_info_package_summary.py | 13 ++ ...regulatory_info_package_template_config.py | 48 ++++++ ...st_regulatory_info_package_traceability.py | 28 ++++ tests/test_regulatory_info_package_trigger.py | 19 +++ tests/test_regulatory_info_package_views.py | 140 ++++++++++++++++++ .../test_regulatory_info_package_workflow.py | 62 ++++++++ tests/test_regulatory_info_package_zip.py | 22 +++ 16 files changed, 667 insertions(+) create mode 100644 tests/test_regulatory_info_package_field_extract.py create mode 100644 tests/test_regulatory_info_package_field_merge.py create mode 100644 tests/test_regulatory_info_package_frontend.py create mode 100644 tests/test_regulatory_info_package_input_select.py create mode 100644 tests/test_regulatory_info_package_instruction_extract.py create mode 100644 tests/test_regulatory_info_package_legacy_doc.py create mode 100644 tests/test_regulatory_info_package_models.py create mode 100644 tests/test_regulatory_info_package_notification.py create mode 100644 tests/test_regulatory_info_package_package_generate.py create mode 100644 tests/test_regulatory_info_package_summary.py create mode 100644 tests/test_regulatory_info_package_template_config.py create mode 100644 tests/test_regulatory_info_package_traceability.py create mode 100644 tests/test_regulatory_info_package_trigger.py create mode 100644 tests/test_regulatory_info_package_views.py create mode 100644 tests/test_regulatory_info_package_workflow.py create mode 100644 tests/test_regulatory_info_package_zip.py 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..0d50569 --- /dev/null +++ b/tests/test_regulatory_info_package_field_extract.py @@ -0,0 +1,36 @@ +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_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..fb8badc --- /dev/null +++ b/tests/test_regulatory_info_package_package_generate.py @@ -0,0 +1,31 @@ +import zipfile + +import pytest + +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.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_generate_package_documents_creates_seven_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) == 7 + 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) 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..506f9ab --- /dev/null +++ b/tests/test_regulatory_info_package_template_config.py @@ -0,0 +1,48 @@ +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_seven_templates(): + config = load_template_config() + + assert config["version"] == "regulatory_info_package_templates_v1" + assert config["zip_name"] == DEFAULT_ZIP_NAME + assert len(config["templates"]) == 7 + assert {template["code"] for template in config["templates"]} == { + "ch1_2_directory", + "ch1_4_application_form", + "ch1_5_product_list", + "ch1_9_pre_submission", + "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..fc1331f --- /dev/null +++ b/tests/test_regulatory_info_package_workflow.py @@ -0,0 +1,62 @@ +import pytest + +from review_agent.models import Conversation, 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) + 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"] From b728703e672f6df0f33c28fddd2f740b4c2e95a2 Mon Sep 17 00:00:00 2001 From: bruce Date: Wed, 10 Jun 2026 19:56:50 +0800 Subject: [PATCH 107/111] =?UTF-8?q?fix(regulatory-info-package):=20?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=90=8E=E8=BF=BD=E5=8A=A0=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../regulatory_info_package/workflow.py | 37 +++++++++++++++++++ .../test_regulatory_info_package_workflow.py | 32 +++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/review_agent/regulatory_info_package/workflow.py b/review_agent/regulatory_info_package/workflow.py index 6a9f05b..37250ba 100644 --- a/review_agent/regulatory_info_package/workflow.py +++ b/review_agent/regulatory_info_package/workflow.py @@ -128,6 +128,7 @@ class RegulatoryInfoPackageWorkflowExecutor: 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): @@ -309,6 +310,42 @@ class RegulatoryInfoPackageWorkflowExecutor: ) 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 diff --git a/tests/test_regulatory_info_package_workflow.py b/tests/test_regulatory_info_package_workflow.py index fc1331f..4f2b699 100644 --- a/tests/test_regulatory_info_package_workflow.py +++ b/tests/test_regulatory_info_package_workflow.py @@ -1,6 +1,8 @@ +from pathlib import Path + import pytest -from review_agent.models import Conversation, RegulatoryInfoPackageBatch, WorkflowNodeRun +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, @@ -60,3 +62,31 @@ def test_empty_workflow_skeleton_completes(django_user_model, settings): 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 From cf4f4456c403c2d91371ca46c30d000e46d7a44a Mon Sep 17 00:00:00 2001 From: bruce Date: Wed, 10 Jun 2026 20:23:06 +0800 Subject: [PATCH 108/111] =?UTF-8?q?fix(regulatory-info-package):=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E5=B9=B2=E5=87=80=E5=AD=97=E6=AE=B5=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E7=94=9F=E6=88=90=E6=9D=90=E6=96=99=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/docx_document.py | 199 +++++++++++++++++- .../services/legacy_doc_document.py | 39 +++- .../services/package_generate.py | 7 +- .../clean/CH1.11.1 符合标准的清单.docx | Bin 0 -> 37136 bytes .../templates/clean/CH1.11.5 真实性声明.docx | Bin 0 -> 36951 bytes .../templates/clean/CH1.11.6 符合性声明.docx | Bin 0 -> 36881 bytes .../templates/clean/CH1.2 监管信息目录.docx | Bin 0 -> 37335 bytes .../templates/clean/CH1.4 申请表.docx | Bin 0 -> 37170 bytes .../templates/clean/CH1.5 产品列表.docx | Bin 0 -> 37224 bytes .../clean/CH1.9 产品申报前沟通的说明.docx | Bin 0 -> 37114 bytes .../regulatory_info_package_templates_v1.yaml | 10 +- ...egulatory_info_package_package_generate.py | 136 +++++++++++- 12 files changed, 367 insertions(+), 24 deletions(-) create mode 100644 review_agent/regulatory_info_package/templates/clean/CH1.11.1 符合标准的清单.docx create mode 100644 review_agent/regulatory_info_package/templates/clean/CH1.11.5 真实性声明.docx create mode 100644 review_agent/regulatory_info_package/templates/clean/CH1.11.6 符合性声明.docx create mode 100644 review_agent/regulatory_info_package/templates/clean/CH1.2 监管信息目录.docx create mode 100644 review_agent/regulatory_info_package/templates/clean/CH1.4 申请表.docx create mode 100644 review_agent/regulatory_info_package/templates/clean/CH1.5 产品列表.docx create mode 100644 review_agent/regulatory_info_package/templates/clean/CH1.9 产品申报前沟通的说明.docx diff --git a/review_agent/regulatory_info_package/services/docx_document.py b/review_agent/regulatory_info_package/services/docx_document.py index eebdc0d..e42d49e 100644 --- a/review_agent/regulatory_info_package/services/docx_document.py +++ b/review_agent/regulatory_info_package/services/docx_document.py @@ -1,18 +1,25 @@ from __future__ import annotations +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 = "", ) -> tuple[int, int, int]: source = Path(source_path) output = Path(output_path) @@ -25,16 +32,14 @@ def write_docx_from_template( highlight_count = 0 missing_count = 0 llm_only_count = 0 - for paragraph in document.paragraphs: - for placeholder, field in replacements.items(): - if placeholder in paragraph.text: - _replace_paragraph_text(paragraph, paragraph.text.replace(placeholder, field.value), field) - 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 + highlight_count, missing_count, llm_only_count = _insert_prefill_block(document, merged_fields) + highlight_count += _apply_known_template_replacements(document, merged_fields) + if template_code == "ch1_5_product_list": + _rebuild_product_list_table(document, merged_fields) + 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.add_page_break() heading = document.add_paragraph() heading_run = heading.add_run("预生成字段") @@ -60,6 +65,28 @@ def write_docx_from_template( return highlight_count, missing_count, llm_only_count +def _insert_prefill_block(document, merged_fields: dict[str, MergedField]) -> tuple[int, int, int]: + first = document.paragraphs[0] if document.paragraphs else document.add_paragraph() + marker = first.insert_paragraph_before("【预生成版】以下字段由系统根据说明书预填,黄色或红色标记项请人工复核。") + marker.runs[0].bold = True + highlight_count = 0 + missing_count = 0 + llm_only_count = 0 + for field in merged_fields.values(): + paragraph = marker.insert_paragraph_before("") + run = paragraph.add_run(f"{field.label}:{field.value}") + if field.highlight_reason != "none": + run.font.highlight_color = WD_COLOR_INDEX.YELLOW + highlight_count += 1 + if field.highlight_reason == "conflict": + run.font.color.rgb = RGBColor(255, 0, 0) + 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 _replace_paragraph_text(paragraph, text: str, field: MergedField) -> None: for run in paragraph.runs: run.text = "" @@ -68,3 +95,155 @@ def _replace_paragraph_text(paragraph, text: str, field: MergedField) -> None: run.font.highlight_color = WD_COLOR_INDEX.YELLOW if field.highlight_reason == "conflict": run.font.color.rgb = RGBColor(255, 0, 0) + + +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]) -> int: + product = _field_value(merged_fields, "product_name") + applicant = _field_value(merged_fields, "applicant_name") + today = timezone.localdate().strftime("%Y年%m月%d日") + replacements = { + "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)": product, + "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒": product, + "呼吸道合胞病毒 、肺炎支产品名称: 原体核酸检测试剂盒(荧": f"产品名称:{product}", + "光PCR法)": "", + "卡尤迪生物科技宜兴有限公司": applicant, + "2023年09月20日": today, + "2023 年 10 月": today[:8], + } + 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") + 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), + ) + 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 + if target is None: + return + while len(target.rows) > 1: + target._tbl.remove(target.rows[-1]._tr) + specs = [item.strip() for item in package_specification.replace(";", ";").split(";") if item.strip()] + if not specs: + specs = ["/"] + for spec 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") + + +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) diff --git a/review_agent/regulatory_info_package/services/legacy_doc_document.py b/review_agent/regulatory_info_package/services/legacy_doc_document.py index 596480b..f95d25c 100644 --- a/review_agent/regulatory_info_package/services/legacy_doc_document.py +++ b/review_agent/regulatory_info_package/services/legacy_doc_document.py @@ -4,6 +4,7 @@ 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 @@ -38,15 +39,43 @@ def write_legacy_doc_or_fallback( output = Path(output_path) output.parent.mkdir(parents=True, exist_ok=True) capability = detect_legacy_doc_capability() - if capability.status == "available" and source.exists(): + 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) - return output, "success", {"doc": capability.__dict__, "fallback_used": False} + 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() - document.add_heading(output.stem, level=1) - document.add_paragraph("当前环境未检测到可用的 .doc 原生写入能力,已生成 docx 兜底文件。") + 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} + 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 index b3efadb..5fa0030 100644 --- a/review_agent/regulatory_info_package/services/package_generate.py +++ b/review_agent/regulatory_info_package/services/package_generate.py @@ -39,7 +39,12 @@ def _generate_one( 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) + highlight_count, missing_count, llm_only_count = write_docx_from_template( + template_path, + output_path, + merged_fields, + template_code=spec.code, + ) actual_path = output_path actual_format = "docx" status = "success" 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 0000000000000000000000000000000000000000..c92ea895b6d8aba9eab70dffd2fd71a2a6b5fd40 GIT binary patch literal 37136 zcmagEWmufawl<6hcMDDk?(Xgc3ogN38+RJFpn>4-?(Pl=njis!JHf4iM&HiNo_%Ka zKIi-XG#6EC-Lk5>s``0K^&Ko6E)*0LA{1r8*oSJh;!nxYP*7tCP*6CKR((l;qnm}J zn~{dMlZC4RiyJ`di%crdLTYs|U2H_sR78-p?sS^W(EbYQKu%&*5s|75lmb|;tpD5)Qwux+XshG3u*pZl5Y>do;3I1bzw_pIQ8{U- z9e$zRX0+ZNQ}uY72=IIZmJF26d0J$l(aN^VMD7UUON%l-*OUo2Zb}?-t@oH@Y;>{G zbZ%JgFGlwoK>=XS>^StXc#dmx9wIc9$6TxHtzp}S?VJGYLTTz|4`6e&UmOMVj+V6; zt-JJe<23wj4nyRKd~PMtvyS?L>1i7&4L&1eaCF62`-R>P-rmv|$G;T;FGd>P&dn-0 zOlSsv!3gKp6s=e56pv5&%E{0B;(RFi#vhays=vf9rR?7&Wh6`&BsS!GcY*$}X9E9| z@D4So=2q^pyJXjWIk><~ktu~<|0$WY2&?y#$%F4bN@?Y5DUtrih0@hs(Xrb%1rNAN zhES^i7)5WIcVc&t(OZLof6Ma z6~q*zt{_S;jjd2!`;2H16}p%O%`K0m7KI4o8qTmjedFnO@fry!BL)XK^s>L0zgKwh5KV^b^DIJ=f^wI7B@NLK(WIFCoJ`JWpj1{z9MvaXPj$E`Tgm* ziiGwo{EquhRcrmq3zmZrNfmWNuea9-lU>+Dd{#|DrQh4OW%rohQcY_6Oj)|Gg}e`N znkGADt4}@CYmQ&^NBsI-pXcnK1&RzuQMZ9i^Y4%XVL=LH4lq-70XVs`ngLuaeoyl3 z#Bt?8HcaVTKj}Fot*9$ZL}_^j=);c+a=1Z@?KfQ9O7`P9Jd^9&MGks-j2>j`f}>YX zBCEX3t*~ZciDBdspU#5JKceX3JujQ$uRP>PGw@d@hYpH&FZ$DL^&Uz|A!mK1G?`rdE=FAfb+>v=pu-mPpq@g+{T9OaCh!$V73<5N>v{5-G%G(;m))s76m^>tIcvooKlQZP~4^_-& z_mi?JvEY2f6@8C2z)*4h=-k}a3bQM9ps1U98)qHPQ_DCXZK??{Yc(Hz)Kl0h0xA3R z?e&EXd*)|(+*0u@IQb5_8K$1ef`i6@7uo06izJM9ycckUhgRr@^s}Cl&yNF_#Qz+Z z>*ZyFBgnYe!$Cn|{(W3boSgm`7L9SoWj4&N2Szvpw~scj#NQJp2LBNMF1bonmzdQ$ zH}*#16I?s{7SJCYiN_z#3W^eJ6ntnJdsq;+eAT3Et%)}dF8HGAyo%gh`*Cz{d2;`a zD_jM9IiA)qmVvX;^jP&g0yC0Zy0&V~nD3iL(}@eRS5B@fjMHvE(Z$x9d`mi&o&3H% zT8K#>d(__zohPd{FRrD%XH)rD3HwxQUFlsmEzB*u4La0KNk&7LfiZdFgd6v+w4qyy zlp#=}AZ9o2KA*6LvqkZB12vfc!pgI9&?gX@r4lnattSgI(6Yes0_rjS0Ek`ptU(VuGX?@KP|87S+=;^lG^VR=w* zh5_55cLB8UpY4PRRb@T&w#aX-iJmZ7E*#kRH^A<~D2JX1e89FwB(l^KB>ShYG2>)G zZT9P_$s&{5>u&`kvIsPN?NvUd4lT=z7OTj1cV)6ZvXY%6WrDAWU*gc^z*h?77)tR{E1>8=rbOhNs$6ji!IDPOdP``@z=TmL_Qn1`BrS zbK0=t!cIC$@xq<}e8^Uc8TKa_xB(PW0)km3%9Y>(rxh;Jp;A&5&M_MUMYBV?Cd1ac zayL3oT!Zh{u@Sk|Lej!ffC*|`ajv5$fS{d7ag4<@JI?RB}avvjkYy5=> zvp+@e6xX;zPKSHEMT)m?jF#=hv}0I~av)8?3yvM0Le*N(Lnxh$R~tJvgD+S~LM?nk zHZkHF*Yc-W8unV#O1>j&G{Tf6s*8O;)v(70X3qxcT&ptJNlYi~`*29>*GDfYrEFtq zn8>%L*vJpABZ@M-M*F)(ukl))IUHCye!4_h|M>ciC8-z=1wFVo7_a)fwaufsIezAs zFxG?+lvLtS4voejEV>+I5AzPVQuSD()japXQ5ckrTc-+jYpTw)k-8J56fFf;g{|cH zGNWQCr?gnU7BRlFi|psqQ5!a{@z68Qw8&_VR#j8=f$Pnr^++5`5S{L4vCj;tO*UvE z<^4BQfbEtSr+NJ+r#f)Y%MNVqtvs9N7pSin7 z*whYt6vf?V0XL#g2g6e(FW{D5Q1Z+0)pL@SZ{@m=oqmX~(S0i~!fi`esi=1)}epZiDh1!>mh36@niBEh_rSXr=27o6Z7r1dBcxDH9I$ir2|D5drc;cv73~c~@*;X_ zs$$<9#8A!?8*^seH zlM~M=^UVc?u@tC$@U#EqSXe*8fXC8`e(>_;>Wx(C_SDl$ae|r`ad$tjXqGemvrc<* z)~xKi{I?Oqj6xjGara3$x8J7EDXE)g*O6WtKBlJb>eF-9UPS z=@WkSyxlH}oPwclPj<$MhDVSmY>i<|(5}|HY+SMx;9;)oE8Cm*Yr!TaprmWEboFK? z?{RF49VS+jRa$V}TrEd>a^-95YpFJZF zgeBpYI7YudE}-dTkf|;93zPYocq6&HuD@!|B)Ju|eaqvYOY1N@d87VgO^}eWNgJWb=AJYXQfBv`gZD}2IRNR z`Ho`kcr~#_2Quo3;SIccFuGFT6Vps0a&z2#I^SGO*0f!ASJ`cdu4cIQZzwlv1rK1s z)i2H)8&Gjm8*iQMe*@i_lLS1yGDNm)nkSJg*$sMB`Vfn41gCYI1v|)jXKXXJZTZ6t zO3JfWGF6hJxGqb56ZW!{Gg_RFx* z@mH-~-MsBBT>n_i4GfaDWE%W?dkYGU9(NoPLZL>d9HA0DNKAaYbL!7Hl{jaQzkMUH zjz2L9pr#Zf*HqKgQm9C55FE(CSEb+8&Q8*>czVhW=(0Zp8j;6kKAo=e4)&t;wCkT~ zJ(NzKUUdS1PlSGg;Js5Xz0;hOTKxZ1P^Q=PeZ_ zuj=pXRte{ijA?kewrSM!eVAV!_woEF`r@Pm7{t2m+`ifadA&ZV{9m=r$IUV|Jk9ef330?2?OZv$uohF?%_I%h9Vk37e>Z5zRa3TUsqmO{;Vp)EB-wn^v`;pK$7>CYN=#{&g-kJ80)|^X>vcgoD<5Ip;~aSX@BkbJWWdDqnlm? z5G#Y4$gLBp`+SY4GgTDm9j(#nCwPKGSsRsbU55c*9C3;*Fdfl|5m8$s`c!A+uPf@$ z7slGv8E+9r5b*T8KWG_%_;kJce-i;%|5c6t2m2 zTzJ75>Pd-d``LS)8F^;_}sEn(>~uV?|Ja|Xp);&i3~~kN7DO6&H~*% zdag%LY=&u(4K=_~!Lu5`f(@$VT)H-?U0P%^RY+4mqv-oO0OY?trl%p>*DnTl6LO2M zL>!fu-7R2<7)m-;n-e{&^zawd}X zUZxpxmN*~WySn;yI_R3k#TeF3Qmh^OGPvheL6&tzrx}8>^lkT!I^djqK_#;_HQE;L zTcjQ1$GQq>J~6CM&{AY3MI1eSPn~@iRK(FMFFF=}K@YiQF#zp&)@+-ugw~H?3pxTG z1P>?A=f4~nv&cqfu>9~SZxCn1{YZ`Ix4?ya-uZ7k4(=Gt2W(& zHD^ObOCRglIoIVEbsV;mFpGF#6%f@jk*VSO_obXE50eT*nVNOgBocnQ(NiK?t>N|= zAzqaP;)Z&wiy{B)qNhM{SgXn|ltWabV#E%1N^A|J$l5UwjQ@mLl+#riwm}Vm*5&)E zFNUdniH{kY)CK>AS>h5uAvmE6z9{c^r!}M#|F2HC&9?w(@qfkFG!s#?z_EIxd>N2m zgZT^lgewt}N&$(Z|07iof))nB{YUDbRB+|AXSS4H|3Pr}o(j@}{D+E^E_n0&|C0DW zPzgSlHTc1)kf^^4^B{)kQNx6I#ckSQra)o-4~fhah_v1)h<~V_OY&^S?D`$`@1`PG z$)+if&K?}Y904i4d5Rt~EqDJjFm!-!fFAje&g4D?Wa*$(+Rq7PZ7p6s>jCXJ`il;( zBvLwad(NFpqvQ6^OfkYN`YDfR!>bP<_jUuvVPEmb!G#HreCtK8EB(8X+1%Pqui9Vp zA}ag4dtVDS?_$?Q4O&4`P8?*9ozXFc$C_o~yA18!^UD`a0a4jh0MO@|OB%jjH&1ozDfF)`VMhuLUb8Tx44U|x zFv#xpk_6DxS(=_P-hDaj*~aWu|)3yLMbJNXfi zC55g1wq$N;i1U2@Yfsfet!UUKIZMDJX2$bf0w`$|RQg0Y7swgay?w}Yv3vt4{bB&T z^Y_L3?2-0yvTyt)#U3HfaK{m0%I~t}HD8C{GU}t(^V!y;R3##;;pvQrY(JOBF5&C{1RGSn4F<6dLVx72HuXb z_HY@#R2;={*ZBPp-r6p&Yj)nWB~H3i6VE2wOB#GV3}>Vlh#GXqel;sk0!`bF#q8ie z@?+kLXaA*TQ=F>iRVZgp)8h$XjX#pSl%}|?>}+$E zdB3_JPV_M&MJYw}gw-ILkk`$-d_}m}B^6;k&!F-aObZME7eH;>HQiN4UfKlJGWl1$ z?$9`-Mzc>QT9n|Zo9Bk*)nZQNDmAcGCjOe`aW;Vzwz!YS@9mpaYifMtRm5Q|Oair+ zvjCo@3_;mym5I$r+LaEF=DRx;l9yg@7_k*)RFXK03;+Xmj7Q7UgUI@$p))}e^hQT4ZerA<3p|5 z+}I0_!SjAD$iQj=6vbDdUqYZi~5KyuC%85!PdjK z;&9lqN|w$@Q_F0_Xuo3_mEC-fB>W3^#Ib9tHO{!~oqGf_xwsZ)0J6nYRv=hySbt`= z#NTk~ylcfjE-s}*<;Rb0<-+fr_^x&{Jl!K!t<1b>=n9mQRMUnNZ!a3PX+PcP`17T4 zU7ugIo_I{&Jaq0fTh2Qb2XBh@^!9L%!XM4R(xOhW@{pm~YwApv1>@jFF=U{lw=f)c zXkMs43e)Q2e6&>@Wm= zs6qF&d^)7|DgIJeLoP;Hf$G4<10{OQ=H@?JE)b#PCyVmtWqL_tET77EP{+7QWex1$1x~+Qlai1XhXrlH{u&w4j7C3{6tei=Pxjc3@3g%r;65){O@oqtx zqo*%N$&E%gvjt4gOpL0AC5EY?b}C2+ea43@f;dZl$9zHU5S+Zc4epJ#XZhT;RW|pL z>Mdhjhw<=ztEK$UDdy(vXmi0j)nesnf^=zl<6NO6+>Po|jdlunrK4rbnZ9UvQ_MwwEWnv;?KPl~SJ=gkywc_mR z9>athCcBYP|H>S33D!lw#ZzQg1dz1T@FA3aF&RFu$i_mP{OG4N*NgE2!Dn(MTQuPi zDTzTk8>%rDUi0bjVLHL0;bcwn?-T6^VSm>mO5DDj{mM z8_L)WFUINF0rK0sn>|6R@M)m*5eRP&Aco-zx5N+m-nVJJ*C4`~+CIPfv9{Kfz`-J{ zJQ!CKRL}cB!N7e8JGJJPc8FP1B}1$m%hVj$`r+Bv*@h!WTf76B|J|#llLPFxML9Rr z!+TV-Tr5}zmd@%5Zqx#@d1Xll$#;>^B?>dKtb8h>6_1ASg~dT1tYW~==S?D6z0q-k zW*T{u;>ZqbE$KA=oMO3n!|lf^Mgoq3B3(;iDLTE{4|jmNcv0LrfJaT4(_9r@dJL!INP%*$_cv zqq*D32j6xwinorKkS5xqXOWOQ5)9x0z)6)1o1s&d~1(Po%&i^a2Ze$VJzuuPa^ zRzr9@RI<9lvVmsZ*cr$q;b5*S;W#3OGkpWIJ}sW~n6+ee99}^oeLeCMW>gGtob8lR z*jjGZ*)sT0Qzm(!wDFEz4yKkQ}bjj z+7BPsRPl-;*K`CyuMc9oO^jTxc|1x}x{YZmI^P{Q2;@%7s;`9v<9O}Xg|4|}ZNqm# zJH!zrNFT&9y*rpBaoW1?;WZ7$5p->CJwQ_z|9DXCHNu0_U5~NygQPna#!4Wk9_L&2 z+Xd)rp4V+t6I;S`d_mgi?+%nk`3?JszwLQJ>xQmK||^p^&d zA*)FNsMxQ0IIy~x2BSE$8*#p&33)fP+ym9ldx`}c~urxSYY{3 zjbr<8zRJ>hX8OXi!7!EHEdxmV4O2tt&V(5R(K+KH=Z#b(C(GV3%;cS0`u3Rcbh}WjW#}& zUG~Vluo}WA>tukMQ?)e7fv(Yw7)w;<5Aw*YiIW*EuMr2dt`(!Ebu}E*B+|5+&w|#J z)Wt+ps&zUd9bC00Td)r}JBkAdki8_@xgriW)j$OX4Wx-D8mg?d3yMUb0pdxv_h+Ue zP6nm5W!eo!p=**2=<3$P$k-hQ*Y3JU#Mv}2;vK~?|Q!XjPgD!m}ir=$#QR3zs&iAnR z@P-9fALVPQlS*~-S6b>9U%v*a^`XEEo!Svz||{7SNyAl0%`iK|>HBl;*Qj%9`i#r+xF$l&D1I*jE;rVJ*P2S>{yW6gYQnMp zH9a%r;4B&uZCR{@1)zulTxe@<=^Nwzy*Z$kv4$#^s@I3Fbj2QEjcYdkj&^A=g8}{O z!}nISE(|a;ODMSLOJMPZH$-^Q^miGk2m&Tjzp2b0`x=EkZYPd4m7)Xx&GUxhFCI@K zl@3Hi(b$NOf$*8?yucucB{7Jl!q3VW1qBgbzfUs59AdB8YQ_s-SmwXUGsRLJ^~!U~ zdy^M_yB_~lu5n|w1yQagsVQ4gd<5hVa3kgSbuz!ND3KPsjMXa784=Mo!buTL zMEoL{#RhRjt}G|r7d-jP>COMJp{p1DlWn@Z@c)ZVN_+&RD@wFIE=yQe&|h;F;z*`a zL&Yq&vKCQJq%>-J{yp4I_Iwr#%)qp5$s1#56u#_v9btwl+Ps6dhRmB^_BISYge0qv zIe{6Wt)@6lMDZuZZ}Mza#L%{b27*2Y60HS@(ojtXt1Q2vSW=$x;DCQUo(sREY~LC* ztIf>}lL%;10CC;46ZygOdk?EF z#k}J8+I(rRowfUpR+kgBdQ=3`iVEa6Bz@81*W;)g*Ugr6-jec_QC9dRq3&KBS)bpI z<5Yb8YVirC9#<-bBWD@`<QvH>{C&3fgzb(w2gsbP3?T zkfLM>vvo{QgpsU5$Igqhc)V$3!AuZLqxUt}&ecWb?|_a|W|CRQA`x2BThJ*=g%6=IfIqViTtZLqZlkOfAxbhWyiBtCeZyI&y z=RR_Dqbtood{3WlLVP2M44HmBvZIX-+OKQeMEt`yvjW1GASMNb$e@$xPxw+yC5%kr zZyK||R5=(yE^>IZ*!cWSB`0sjZ~Hf)wEi*9NGO1v1C(;nyve%mff%^gC}T^_e$`I znt5gRBTFlW&@mn88ZIRkW*mm>Q#isxQQi(sJCZ=EO*2fsNlPVZkfupC=WCay77NA_gN_27^6iZ4*-+jn;JXHi`((txn!@QkSIpH@&(^q& z#F;4G>>vV{j}JbYBz6e2)(brCxDJ61<-C0~E%NqBiq8tO7fRcT z;bgT#e0J}dot3w;RuNtg@q=c&IrgibP1U;%_G|Cz-9 z-%0i?fO6hc+9~<0Bt9z$)sVS^aR^{5OJ{_3$|mdUx9wy;E9MGnNPpupMF?LhutR$< zD_Is==TH_JbN}t{b~}1`(*~Y8QbA{Brfr;kt|{|_3x%vk{CR9h|B^$3c$T{8Dx>Sv zSO_r_D1roN&Z3n!jm@@-?!PEO3dQj$?=t47MN`(_!Fvc z1!M&s9N8m*u58zS5x?x3v$+>WVqw^2H47g(Z&c3`RVd&HLU#mCXfTp(n^90s$4rnS z3DKoOnN}R=W-O1dU&j%ad2-2ZEg5b+OjMLO=D~rq=8chBQ(f>=`iSXqnEJ!0+bv_# zb(%IP4f&b+u?J0}62dys-u;Zp78%5b1;rt}d!)!ZS;|ys)LxFDE^Dnoc-MBq(^v<$ zW|w4)#KQCybTl?pfQE4ap-v%0fA^bO=MU->2(^&TAE)mrP>W+7+|?h{RQeF=<$A0? zs8d+db=>|h>RW1M$Z`Aea&KJt5dNJiKoc#+Df7nM1gXmmqaS;xnR?0KC0&Q4U$V+j?8&y(Ld(C%Yb7D%nF5%9kjLgj$T$B_-!Rjj zU5|Sy|02gjcsG{XF`(Vn5nGUeUy>vumOB>f_v`8M{uFKEq`x%#yXL|1-Ga2X7IyV$ zFaGE;H#Hyis#U*>sv^KIx|r*95CKi?`&4}f*^}MjiM?igZzFevIlnq62jSaC+gb~s z!?I4|wVsL-|GUKb(3*1E`4jmtViQ47{c#I}BF%_h5UOGsL2&UJN)VmyE4uVPOG~hB zZ8hl(McwR~Ar``WPTebFjLgHgR*B9k*NRj=G{%DJ-0OVeNjVh)hnL~%O*apT*2*)7 z+L+(DFqJCr1{Aw_QNK5#f8z?;L%(!2(kG=7%PU>)c110)Y1rc>6PTBueY>4wogu@e z)aOxR+vA);BdqB@w~JP4b8>4llT|WbOHp7$k)gH5IIebuVcrMhTV@PF;3+1|X|MUPY7C-FwzX@5ukit<pWE8FfurgEgm&A3$=INgKT<li^PiS zn-0GXPHU83GYuCmGb0j%Y?HK*Z8Duq3>A?Tg0=M+VVk5?!GvWaLbK{S$8Ysn<4FQQ?fT+jx5BngyZU?lV=d>e&) z7X@n>tCx8BYn6x#mH;H}x4E>-&h0!2$# zbS0-W6AW9>s{|NZb(93yUoenk#sG?wc%qlMp{mMxu%31#`Zj#pD5jyY&yr9Y27e{B z?%%|rCPKuGP=peEG?G-thn+Twc#3>|lLtb9qW}HDp8)w_uLGeNi#Hq*6v2bwS?L^9 zPT33ty-_#eZwdlG@m?;i`_y+;&4W=ShoFwcaenJ-6c4822)-lrGRnDOk9sX1?_!STV3L zn6%K*zjfW|mE3-4OQ-(f@@mw&d*C{Mzd7IjX(K#`P>{IziFZXr*!6Zf8QzaTS+@^M zae|uLH=#$(b?!ZAE&K3VeTE6Le(`r?7s3uPVtyFIENd&|&RV#?M(%zBD)Xnn8t;+4 zKGcQG)w<878x0fq9LBzj1iJ@JsAc=L-jjbRy#zmum}b zS9;ib_m*{=`yXW}Z_Tv%bXDXhrs164P1J_SQ0zsRrj|tnj9!{v> z6$g%nvBK0C9F=hPURnmH_;8Aw0!fX5NkK}j0j@ICNLtvv0Z&uu)dahfKTA&^(KvM zaCrgld7JU8a-%<}TIy1K^mYEAn(}MWX*)(F6;IFY%ggfz&JRl5fHmWhm-lG}YK}#E zE1Vw&9y0*!%TpqvylKdy)a;_Lea!5=EJQ(RAg|2SzPX zEA2#WnB}H}-~Rq9^isGfpZ^#tuje_l)Q&WpZt1;+XK|Rn>L)07qQ{#IPCh#e0UTxW zNM6SjDCus~oMtz&L<&Tqp%$o^N=njwUi?xAN6pz{pNRdslh&;wY!HiwpRI{LLbl+C z+FJ^m@UC4#>?@(UNID!;CYN(TQs{#$J(UG*cdCrjFQ7_0tm=E91B>34Y=y}@=9SD; z!2P|Lra#<*j{v?drjmiC(l3?+4*#w9?r*B&nkc7jv-=BUs_~d4uAPjfRSufS^q-o@ z7%tui=V2R-zVoZjiYi2z;T1h7{}RDgysw<|Wdtf2d<_DsmvpW1iQ?TWGGFKKi}`m4 zD)^U;l$hq0&&F2}+jwNW25TMJ_xSFLXpn0&-X)XISXPqH$iRy#U}>BmdG*&O7k+c= zf7p8^jQ5C116m1^Tq4X1&h1$PjD21p-Y*T#Er%DqCMl~WQ0>Q~#{i*cv5h%RJ z{}KGC$b9#wU}PXfFob6|-epy%wlVr^tDe=>D&t-LeVbl??W3Hf{*#>Lzko+kfYl^^ zReu4`6jQl&S@R&2ZIssDW^(L;M!@A8R<`{Z!mkdG;5E)Axq0v&6|;dFt8$H0NB;l@ zywy7I%!*Nl7iGA!NqbYaG3wQQw7+^3d^?HZ!*FNP#6F(GkR1!naQExzsYKDc+Zo7k zm#%j%IURKG0}XKa<;@%`K>3Gus>Ub9WVWugb zzw)1h6Kr-B(1~_B44K|VNls)fa_wMqh1iEd3s6@as`YvKh^=CGm)81w8H!YkV^T|c zA0ffE&^V)26!Y(0?#ZdEXpogv7+;0J8l%ziYQ)DmR$z;T-B&9hZYlQ|kzang-R%Uu zv&Mm=MT~QnVF_}A*))LLOfh@K;`<4@|D4CT?IbRk-Zzr~`T`%O2-^4h)kogq3By3= z4;Bo47h9j^;rY--YBDywayQz}kv!l=k>WhD;yP1yb$Do@5aU{%a)KdG{HYupPMAYjXuDQhE1nZ@+muYMLVrrtmY3pHUJu31o|&B z&^RE^`k7r~Q2H-w7XJev-j@La@LuIlfX-eV5?a1BX+FnSH%>#(BeN#EZo%8!#OAns zvlogh6mX-lt+D|dUg~4V@Fb&}B`_qcWFxA9+Z!Q-h;g?_aVWQz)c;aI6hm{Gh64pR zs;?s`lEpXM-X@M1M+}I6?z<@YyoACRSE&fkXK0`N0g}!0^-p@iQmjAeMMwp=p;;m3 zc`-))mkm%Cxmip8EB!Se+2~nmtJek_H14;%T~OyZah}d&u%cey7Qa|xQ5Dx48>BEs z_kih>WW^kYX59v+?kj|9RZV_dcnm#``H)d2hwzUxcbT)Onl{G~!A5S0m0-u~@gl@u zcKajMgXK2YO4OFiCn7nZ?~MjJPd=1zaZ8Sd?ba~Hp<@vk+}{Kp1d@MnAB9F(&gqkH zsO6%v5ODf9w6l}-wZ*s+E4DhIfM{*#(B61&;bq>*IuW@A?TdcKq>D-({a(8f3v;N@ z=^o11HpPs%5%$)i0k1wsS4ho-!jp2eV+dGXw z2iiA)k)7Stl+?oX$AE6kQno;3bh%4?mW!+Uz^+TTeHAn;4*Bd?BI_7?5nU#~PreSh zNs}X2eOForHZ- zvUk6|kQwWMcjx5iV`62>L3ifJL2hqG)_q1$>8Mkl?m zY-r5Q(xNaSwf@LqDLgpa%mSwVT~$?KmHke3T~6CvPlW;l{Rs%LH$E~}~*UuTStZJ-rIB~vywd{}6mfZLIP_-%Bx z{D#E{DnLV73egmXR3uh6{S4l*FDJC2non?a49+xFM^{!io!i68%tJb5DKJGRUCL*Y zo|l*U1It3!t4bg&4kKhBy?pCFOkXW!ChFptR{GY-tbMUcGWbyq84xRrVT4T~Erc^1 z@oBT{NSd1%M>~B z=A#%F5E|Zw{xlN9H4H;jU3MElwcmqgEyjS?qJ^*gFBJw} zh<~bp%Qi*0bxthB=u3K+B580*q>D?6_p+>64%+S1Pd(nn*i`r~s+&nZ2I_xsh* zw+y}!Vu3~WE;vS?2wxD_xQ%f-R&ZGf=zK(+pV0V2$+=6F*9vJ8Y*<7u=GHPLa%H9M z9@$2G)sZFN!&Is`?%n|6ZOvk}{^YLhf^Q zsX~oA3ym4J(hT|#(S(?Y6w+e%DPI%Z4DdKZexowH2}|rwuqlFCN0FBcN|4Bw6L)A; z^_A-A&SK%^3v%tD-2ok8Zk7Uyd2>9^e>?5 zKY)<`4b%t$%6~RH1xX@pz~OpcYA^><7<1OGtS))j`VExsn1a?l_xyiXEemA`PQ z1)hbzEfSN<*DA_u(k>G&Qhjlrxu*>K?W2edIqO$iH*wg~_JDQKx4ywG($_iF-)P6Mi|FaH&~u zm|$UaRXyO!3uAP3QHG88#=YDH{PTXkv3Kw+KQ|PVYb^p4(Le9!YumV4OIuo6n7RFV zRo~wrwNR$v~_HzGxs}TXVm89>?Cq{1Ysp;Qn|%ggWTtv%s^n^6jC&gKE{}Rn8T0Wy_AZ?4nY1rckL*Q* z&Z1YJ%G*=XjO1kmTg0fVyyM2Ju=Iuh#dO>? zX*4%9Gom$h*C(K@+jDu>)nzafbZP_gIpJOUk(i{iFT+62cU5O(6p`~YFJgIkVJDyl z?Q-RL`$Cp=dQ`C1(V1_9@KaONIIEWv5)J2f`DG>Yq=DUY2@71QwMTTNtYQ!1vv1`qqfIK%zP{HSsMSABc;ALUepg~C3 zk!)bMba?p+m`cV^vi#g2Oe#nwKrZy+{wTOsb=%olyUy%^Q8{1x^@+N=w+dTQbmB+n zyuXWO`{IdJ=lmOA4gu14)1t)$90B0EWh?Yom80`domrq&qpzC1tU2rJf>V>_;F{~5 zE7K>ipG*7m%{qC*Z94xoJ3IIpSpRf0c>HtV_KaiI>7HugeN>RP)_OPoz2j|%mkz0; z`Grld;*S>dJN3%xN?fj~MbVdw+E%v-e87X!GmBxP=O^BDGJ4d*&Gy@`Z*Df*2&f5| zL?Lf6?;89b>r|AxdPQ|q(4QYhjan)vd30CS*HubOL&#HDE6KIuLrN*l}F5^7`Te$=+7^WtDEhGV~WDe1ixzhd8?XvsGIiKhzS6r)V8BkwpLPeaVm3D zX=T|+?*-L|J#I21sMH!A+SGG$SHLd!e1WVKiLBPV%de~Eji2ppWir7d(KJ* zXFk`hJF4C2n~ggo+Do(6szk5aY6D_T`G88gYpd(6m7{vTg^5qqBLl3{(Gk0|dtm4G z?gw}9sA0vYTwYJ|IFd48ACk{*>HL1_lfgg)xi`~2HEh4dPU*a(5g>mjzm3TWt;Psu zl?dfUN2F%Wa7uW%|C3+pK%6>YRJ}e49eH{%=5wr*P*knyTT;Iq+SiQ4n_h`g4Op#f zkt+;%EG^hkZMI;@n6&SuMEJx_c zJ6)I`ku8Bf?}qfDBE$!n0|$vV@%v0-olrK;$I4rv(A6~{9rQmT9TX>!js!?YolG0N z)4w4|ejv6&J!KI0Qg8hZ`YZc)kk_OGG)YO>Zc&s|GNr8?pAoGw1S^7 ze2U*4p-B*ITA|QGLLp$>|Iqp`Nk3r@nXv$nFeFHr-M_;60FEddY|_oYaaLjYP-lMQ z{5{}rp6g_q;OF$;tUeD>d$|rR}0sZCzm_?ae z$3d{8N6ffd)6)#bDO+gQ#*JW?(OdaI{a_My-^%#u%E`^*GEo3(jNQ8N%l_kO#O`kP z*uX7L46(eZ*En#^gU{psRG{7OW#4Y*(C&u2dUTr?rPOmAgW&nj3ygT;;`{4qCA$Yt zD7$m*;TKEItCmjlk2y7Ds9Wu#?%PLbWHEE=OS^{3!ffX2lOW=j%BT2f^Qz}|mhMf^ zWxW5dulw`&qndV$qz21kyl~r?WcTMQ+`I-*>tL4`-;RV)!52H^?IuRa>t#@aBKCi zuQLFB*WNDRat7H{z;Z^M*=fA@=>_@B)5arHjp)bHS8(J%1&{I0&IlruwtV2{ zl_l#R(P$--gvB)~ylwqj>jNkOI^;#Gw;wA~Z3 zc!n9-Eq-5%xaB_+tBv^5p>QPyKZKa0LFH9|)t}TuztC|y0Se7y7~0#$titeO~Gf!rnOe6A(>T|}v0 zk~l-DSxS{P-9zEt0tl zywZD`-5zUrY!Wr(AyKSgd2Cxc<7FjbcEkYO%yFg1X9jXpRbf0_9$*Sy| zS(SOWa*Y#8oQ6c~QxhBbDC`Re#4~D+PG`7J)i-K#=N@J;TfpBaU@wfC6}T09rU=`( z6OE#!u58KQ7l(GNNYm?ko*h z4S~OM(FBworVK9KHP<+Cud4>8+LxHf1_9Q4HLbfr0G|J*Ux}^77SC{`HYGkbIJYO> z^D?Dfxgeq}%`|wuo64^jTDfP*=!M~k^CLi6WbLB<;ytY~w=1&2?P}k{k2U!4-K>|< zqREI0`o*oWV8|;j$Nbym7YjFqMS8`wg2S8jdj!=0`3c;MHA)OmiJT{YydMRepm|Za zZm+(D#0BiTh;BJ@i1j%8vI2wB`dEGZ&EvF()s#mb^xZY!kwW;jUFY?>g}4_!&IU5gC~oeKJIh91U9`uaZoh)cl3%AJ)|{w{7Vk5- z;0xp;w0GKGHLseH+ng3miKfn!cG+^JH?LX@Y(6|R8awagFfdj9w0Da=8jnlcO@qas zakW_4`l4^Ng;sm6RCY{Illi@jfZZ;1?Xi4QJMP2B!>puGL;+KCC8sF0tI2wju+GwV zl2>InClxg(li*3`W}%YW?_y!JtXn%SR_AiTbMevoT^$9S+x7eyrKz)d(#>@7Db!=B zqpR}d{RQXHyjQV|qj=Vwx5fIxfM=z7f^oS5a&lL&?!)A+emgm8hw=Oz1^VD#1wT+p z&fo@<@N0(~8@J{=J$k>+bJvN}XPU65~eFB(wJo~kTD-`az@fSht9 z61}$I_v8rRp0%B;AI7fk52-(%(tndjiVU^D#up`8(Fh=I-5m_<3p|IhboTpQ$J{IB z5BWGL+PwDgZf8#^4qlx<0G_^n@$S%n0!iYYdPN$}Tw~!s`CuYx3Mb+be>%fTC^A__ zbY;st6m;Bre|7pfF}D3HUL;0DM&cstc0sP%vKs5|6i!VwphH>nVwB4xsL1i_)^XEdfTlm%v&pZJQy9rR z9uY>s4^$cRh0}R8(B>)T%^V8U%UpC`AD5SYom2Cg#j{e!y~}Qkgv5%tCmtG1R%Prb zPQXiEG)RvHI41?;=Ad2XFpDJpZol%>>WRmw>YlGD8^SPsoiKFv>S~O=sF(y3Yu5OA zFQ*ty~rGttLw)4p_|+ZW=XL9sqsw{Ct&De6Ktr}{hUvg9(*-R^sKtYdWe+@F@MJP7Sced@*I6C#{! zXn9>x5qCKmjivN&8X@_v+kOrbodM>Qc8@nVqy}n#8r{UEsiz#?gtC%m)b%{vz**#L zd3~ZonGTf%$cVsb*%d2aAfEb6;~Kh|*+%!{O+#x1%Vcj@yFP1v*tuzJ+U8ZwEKn zbIx8D3)no$KuarC<1}utr@<8qYAb6|HVE!#DapPe=T=sOfZGacb)sAcs5tiK4 ztJrqWzAfPm{1&eBxOGvg@mE9amSAsCxYihge#7)Ssnm z-8|7lQfx-|){I}#ZemJ?1ow8L!M3t&IhN!*Id2>fFq4QR5d)=8<%hwgjYQP)Nd21a`6pv8m5?553Q4+8i4N=-GQqu0x=k=iKYoY3A_@r9m zbLZpEvdHzE4e~ZE@($rKxgs=QmjGkYgNm%^RLS<|JgrhSI;45qJQxDTyJ*O_t7k4I zQ3?VgEDlrhU&x+A+g6L))cbY#v8lUTD7x8Sir09QwK=odvb+{T+RRJZLb(mk@GLiV zK$)~5LMz+UGZgqPYm~@P!`f1Ns_#^H50>WQMGqQP5IraNYuL|g&~H=cYG52{pdIA+ z!`fouw&72K%Jg0LYqZX5j1aJRAiqDa0!60=6kOD(l}gNc*`ReS?AMqv+VHt_{CZ?1 zW9+8_?Rr4pd}j zSng$$va!>&gCkb?)x&0E3P)j>;f|yc+iJ(GjG%FRNXO=}tA%gpKEnY(aldB5LUS!a zpl2WMdO3=ro1M0YeHvKgRB&?#$A5u^NwueC^5{v+A%?`|OLxnaSYwZrv~ z%@vcBGVsb12u*PaF7y%coY1v@w53>u z7zjv&m1!7lX&(NNs&jpEt!9ScTAm3AYFXF?=a@;|zk8-1NYGwj+${`>Mkdl+1PqN_ z44GtHKQys0G#Sn8<-8LPih+PCGE?Ul5=p%tYJFLyo>h2%cB8vC_^5j&1BvMT0h;)V z{jdGoeE5HdL<0VI$jfyLKuEW%ze4gn zhb}i*{3~QO1QLm0ct3(%f3EUm2{DL^HMI>o)+Y5+43bckN;;#c;T+m1oV^hQsi@%{ zX%NDCSE?(GD3GBgL}}fOee2+NM>% z&Q!x8L1Ha%*;u4uy05c?LUuKUc?f~J${0)^p#S3mov-OUu=iS`dajZL~C=i%+0e6fu7Omipc{mVJ8{tu>2pH)O(f|}O2&xE}K2z45jJ0|k zjBlVQ4RgS12}%TXGh>ptY`$lQHrOD9XjAeXwqQ6AF$gak@C|m6_eiB))1(bDRK|RV ztra@f%Nj>86c0-Xw+*r;YY20YPWk{8vgX@(KIn5Tc@7LR5LRXYad|G!0^Ka7Xl18ucOtd{Mz-Z4beYMhX~-gX1y$p}kmv z1nggcjb(gcg(t4h)&8f!eE-i4CXY|@YwX_X>G>Wo@0-5$2FMDy^j*Gop52fH(1JlW zrobWL<-hnE#(&TrSogh=s`$RQr-C~T7{!M}OyVZNz)xl+MW9aVB>jFCoFo_oItc-$ z;rlTZFyx;%fK2#7i|~7FWGHQJb4F({>>+j2yGg;BhpoGrt+$=6r(1F|EJOyX{9*6b zAb^MPhd~A@E;`ae#1d|bDt$*<#|zQ>nC?mASTy>8>^hD1xrlH~ zNzO#g$#9T;1D173X3VA!O4)cd>H`M4$dd_OyDPsiJX}MkD3%_LO$8AL3^( z@~6J9G+Xa@bNw`1-#nJcPPYdOTy0!De8Rwz!|2l3*44~b@YrtW7-6Roo)|f-{xg-2 z?}HbzzTfwu*z~o$cS%J3K4$$-J_5+YL}OL?V+it=bJ~ogjr$ZhY1?1Y&&A!oT2Z&J z`%jz)Pg4pJqnH^OsTn7jI3{PCK1?_swspHo7S^|~+}*y@Gdt6A9`7~Y?@HMo)$$X8 zXbjLe)a4Cb{w-{6w|DIa;0mVE zx^0x$f67_|FkK>e9`63AmO-f7#j5keQ7{*Zpu1MHS5(gGP2|p?H<-vD4y`j)n7mAD zfvO|a_aoKsdrP+TNw6}?u<|Wn4(&y!!8w(&8oG2C{7;n5HNw$-vdm;UkBy?+*O^N( zkM}VaVPXBxk_)76-=yx3J5zqBFV7I{m0S769N)-YlnI|Iu##Vj6-IKmzP=}7>^}kU zhA-3dP+}Na=;>Hz7}-YW+k_JaecWU{-T|(qXZ2)c-M()Ae66YTv>R+S@>4R4>iI}q z>@m|95Cb;wc;A?^g!v^OG|+AubWh7kRUv0Ek*)d-0X>N2m`C+$0v$ND`TwHl0^t}| zf8d{b9Q?3lNL%4AFUhpeo*vBZiX?Z^o(}epV7Xt08sVuGG_1cW&${mk4F-`t5p~>o zi|}j7Pyr9Z0#$0&D1p`>ho2Jp{61Xuq4rM>>N?iQCY6-I%*NP4N5zy{l?2v^VerKV zQpGp+5|~Zg@hkhyn_c`#vwC@zu?(wQ+Gi!#%Jk8(I<-8I4~%8mtiSK_-omAxBHR2e z7YL0iUr-JowbJoiQN2A_H?;@O8T*K3-0WRqfqOaCEOfohA8vNB?QyN|4ZdC^un}$ zx-eUirTOXo8nC3B@ZbXPBGSw>avD+Q3MiZ@P9e6UVY20@hQUTbbsgLSoj0~9>mQ8Q zj=?DAeggEy5KD7LS>j9~wkDUCOQ2#N;{7vxt8nv^a2ceT3t$Kq_yssBCTX@qT7?_M zRLU?*fQZ7sF#aXtLQsV5j|c#(|F?*kVbLa;9{s-sFy})rz|nRSm~R%bTp-O{g;4*Y z<<8)T@2^TphSx(1zc7vG1jsSI^b3{}X99zZN0|4fTe?$!1@QS3AA*fI(3eF*I=Dv=W(D4sSk;OD|%i?A#FAz=?R~Yj;`RBl6EpKwwB<`YNZv6G zxZhDrvfG}JBP?d7xFf6^m%=wkm2efWfdgAYGEA~IZoxzY&r5$1PnytnIBt`w%XCY4^Ph4OesydRT z{TquZag=0CLCfHKai>9~jg8@ZiR*%H973s_d-XJjk4iGj)eX-J^?YYz#GAc&@dAQ@JA}T!QmjWBwd`L?5?c@GU(OvmpI#lvc z0XjZh{;lIqeenfyuZe%B%d(!&oVMncj&8QI>j3NOo8A6_ zW-z_+&c53q&mnoDmT1Y7i^JytY;7%X^zONbT`L`At7O_gjAUYmbMCJEiOVu)BN0QO z{*L7mDzPyhe<}YX%u1iGuIpqVg1)E7r$! z27=j%EEFq}xn!ezWD}lK zlNnCRa&!7cNf1Io=v`+u`-TrM{Yo@#TzyYboz`Cyv>~)uIgo3Oxfmgcu0uQnGMqgdiXPo&7-dpNh_;JiH5?v$7$=-v z+kYiaiBqRqi5fDnLZ9ah$p(uifE``6lYW&=;6GfIEXE3|n?M^NjNzqX2%Tz8B*BTb zoMS-50Ai4}X;#Bigc}0Y-Sw)GaasN-Y6hwzEV6IaEK(kaQR#7=CthXh?Q1A~)+igv z#0Uj8hKCH+MzP_d8Rkw#KOkBatR}9Ir@WhJ!*2OuI>9b|DO9~LYUw$PQ$V7xK0jF- z#cnJD0SF$0VfbXSJcD7QD$QhEnTEhKj1wwtD7P|cAH_a{2s(^|0M$aa>bT6|MrD&F z3@}YZSwk)R5=*&7EmgOOif$N3J~%a!-T0WYCO<_F(`Gmn&KQ0ehra(}tUQZ;wHys` zRFx6mHJm+KzpZq~`G1qeL9_Yj)%&8Z5g=VhME${Lua|Y6UAPaw=x3{T>T}EWHHZt6 zslqU}o4F!QU}l1hrjX*;!BH!i{8j!B|{CzH@e3ypE326WLIdd zqE68m;+Lz$4G%G&H}lXMr=moDgr7&5aEpG~Nj6)#ZSxiauc`-!?xLL1s|q)n zY^~HlDTWSB2cs;e6_p?PS|#OAV}2_lj*fnURbH5&${cNRm@)Tdz&1bfPya=uz9urE zA{OaIl^?k=C0TB~H+!0tI4xuUL_#Hko-#e5C`KNHqN+5Ji>5|aPbGCqJmQHJ+Qj`6tSNv7&=a^-YUadzQ9!3|YzU$;XDp)T!u*q|^oL@T&i^K(`)XK>{qs9}8x>B9mT- z9@nGx%Ds@2y)=|YvX{FeX&}sY)KnWNf7TXuiYRHjWvPiUyGePvGw1p#wEQlbd36e7n0A;{j@U)bf2Y_-? zp~U~7Oak&x%3SGl=C=AHnT8ySVli=K3h3PY76rc|%@%oBMPrRkxEUk}vokR$o8RoF z>NP%A$;)Z{4wbuAn>~e4CRg~dEFhh)CF!l5qm_=p7mf%m9N^0?j)Y>qaIS|O6mKf& z2-;)VlPRh9XB0*mVa(%5ZfzM9-?oQfBB61P+SGm=Q2UpCyPZb-ymJQDz?T_!Q-`0D z2Dw4K%1c4hT>t_m!X*@tQKq3iV|mLs$D5pXMY1MZPNrOrp&ZI*^G>c*C0*=Pbv6rl zHfyAYWn)a^*IM_t>^0>FMY7{bc%PNrpoq>%e{X;Y#)aa1<)eY(6c)T}_yNBUv6oPX zhA&!_$T@A7Vt+LuAWgI5nB?U75}Nm;3bZLi=_)38=pho`Y*bT{E8cPPAss;+w9Zt? z2_{;IWTi&%QaQs8{Ck>|DeZi>?vAz*XrYz$3!LEKxv07}DtWdCN{G_{%EX_irD*f!H;?{1r1{V!L4a2mu-r zMc#9995B!zr+(Z5F^M0irJ3(k#ZmgBG!zD)luQ+%R9=xEG~SUXfqq94+5$~MdPm#% z36l&@k}Iu%bBj4F7_=BbiQ8k5E{>R2bl?aSLz3%x`I0Ebv3;VwJ9udBCv@+njW3!v z>As_?rV|xywcubtu*$`u0z_|`6nKh`YBz`pQktWvc3Og?==ZdEN72l*M8{!xC~2;2 z2%>%0!pNuulvvaeW?rn}3&uZAF^D*{n*iub1_Up;+=>=4#jfYFBq3@N?XRmyM5zw$ zQyku-APDSgK$^oAt>Z$672RiuXuZIpAdqF>n^OOo$lZvVEbqr6bKZw)W=MgR$p4n1 z%LOtsbv%g7L6iv_HU~tOEw8G2dOOBu(aqY8Ll^Aa(_ z#N%rt2?;_t7tw)#xJVlbLILh5dBr-i3lOi!^njqXTKQ&9UQ;*^(Z#%88jK5uQy2z? zs^0;^>(A8OL9_%&8K%f}P^u7jsJT%gy!)>0aDJe*aQ<}WzU4#2Si!e_`q z`2Pxm=MsAZzJpw1j{F6P^0Bpk2YBc`?T2Z{cDMi_kig%8_##qg?;uTeg#n)p;~ggK z#PPc=qMFvw<6>NXStJe@@XsUuFa+z!gb-pfwg@4Y1d93vdVrYTgK)`RCK~CBHF4>zS1C<8m<^!svYN~NS3HhAv_D?Rj!_*T=A(Tl z#v2}B!gB<8_Fqy+1AYxb{q30GTS3(wBxUBvOR~pl2+yY)*x!y}3DtHRa^y7$FyP-k zl+(mOxnSHuPHTCe(ETB{?yvGk5)*zAK(_K(-6s=_1Tt^%=s&gi1pEq$`;U@^Um}1> zFR6U4xm$o>0g<->C};GzKSQ{_Z@vU-CCEm9T7%nW?vI>1NL)uiYaAe!ngHY|!n`9P zoHQjWcxc*l9w7F!A0V1fGXenH*L`2CS~i=45Y9KchOCachCJfAx8<{m6;h8ZHDX<{ zF}nOcva`&BhWtXbS`9uL=HN@sQ`*TBXL$@-GT_*+DM}L`y===n62SW{iCBV3DIoA{ z@S7r;=Lmk^HhbtU#%rhEnvFl!;fvW>HX*_Q1EXPmSBABQ-)DMuR>~I)mGtvc?kE>ihMmyi-3az~2}D)s23SYDlSdSn2s-g2_;$I z+p~9?V=DIHx|PMolf+$e4j9;t^W4@kM?FwjhYTom+D2}Gs%s{`eA!0865x{JDD&jU zg6C^px-tzzm1oU1=bHJp9`SAL4zJBr%ztT%vCQ+LqxbSI2w2|bYt9J(rtA+)+21gF z@?|f|Q}}bT?+a4@2l0Y?%5UvCsB(-HPD1%2>|B z{~7Fyd>QxU!k|^?l5NP+w2C6;zvyg4Y_H?TuK)1ytj@ubcL~cmQM!^FWA$a~x4Nla z+JeB?WBY1Fw9mH58Xqa|B31B!Tb7YO&izLUyx-Ea5;($tfN};b58`Ch!MJALoin%h zhqEBw3;`nb$~xfb0BDPXgI28;MCdsui5J372z_SDFy0aE_T>muzhT;9N6$IFej5&v3=%OFo*A0zLS$+IdVBjZkU{wM^Qf8H06*FXL(ib%%b4dp|X`Elq&1XJSMBTLQr^X=H#>7Ie< zv&P5nO&v`v;bPKQ%qh6BA4@fHFyxDh6wFhvbB)Rw8OgN_6*@>I?Aa0v^*t5 zOR$5w2z`LIah~?Ubk9qA&H|=agcSnaE`k=;;vl~t|5l;#BE(64#<);zj>4=qP`LdH zS^D%T@*L|%QPwn|lNR5K13MmuJ1#En*3EDhRx(4Fl-eK6rKTjQnWCcn6Os}TlKVW( z8AYLxiKfcJqYGM?JHQ#_SQO5|oQCqSf%WV94|rA^Vj=F1Yz{SUYB(#S9&SQ=C2lKF z7e)CaqP5BAMF>GxTFM2gbg(NhQMypGAZL`(rn~5OPpZp={Ot6C?Br!EIu&^;X?S67 zYF*20XJsnZcv5(Md(Si_d6<+>=bEmNRkLK&glBTV;iMq@QI;EfBq6q!p2uPNmItt)vRDwrMQMr?ur1u`a|7AJu7D`;P}M;!$ZLd@RVyf$0l zU_8e2%fmhfdmslBWQ-t!bB!&d9gt|7I8)vnq!DwL1ou#*5GmAwAy#7v-YV(-H4N)c zEmcL8R%y0k@>;O7a{D~jaj~K{sRYj3);&YY0;$0@%i3!>AYBwk1f+{BUQld^$HfMd zQfQ;T(>rSqE&2J`P_&d=QHfucF(#!nl2I+L=0>leu$!Rpl9Q&aR8o@u#fgK}tJ$BM ziqbjWhO$Tk7HA=NVF+<3Jcl)Aq~2Ch-n_^WtpS{2l~jnES%jOKx>g%NX<0MmZ%U$~ zct{%ZqORE3$D+mGpaBO@D#~|d`2(tF5)O@tz6iisqRG9lpL|u&swm%Cm<{$ZPFN59 zr3r=TpDZv^k^s&ei~h$MXB5Z+%zBrCMyyo$Nh!Q(3)WWWK`$~YN;_&wfg1PUI2}ih z5uKuma@jBu!RjOdQ`S-yDkkfqDP26SaCd^G3A`>FSzlPct@+sJe%0~_#XIhmD%9`+ zy6!bUSA1AaXcc1GbE5GSqfl-O+$kqh3z<`Tk^cp_MbeSeu1Zep=F9H;EAD52?gyl@ zQ9*VH+>#7+6;@y>1_K!VwTT)xlFHvNJ#isfcBDVIFcXs#T9ucW!o~06KNB*^%iLND z8;_C;71746_J!k@_%UvPxxxQ>t(c)5se*FH90cb!8UP#GQE|jCtwRSf?fDg(Pgv=3 z$I@|^l#c*AYqujEHOI9e3(AX$vuI%!MhMPQp-x!sV>=%`Qn=ubQ;%09D#fedA1%if zIu7>~tP9E)Y4O2rfFL~lMZj~f=_r{5n(to(D2^ex(Xl6-*mAr<#1v%7%0ev06PB_T zMTp9X%B01G#xeCzV6CN2q+S?(x@WytJ{kB@4k$h_H_rGz87c7E?pn_gG|VofdJ zeIsPV2lTbJk+R{-bjo}gc~5$l+Kc)(xirygS_(N-euDiNa8v9yjtb}UojlmZpIjEu zV!q1TqDq^f74xFA#q#GR$x=D}xUiUkoX;Vg(vI79T;3`rB?Mn5q-KMHVE^;B=NSpwQdT0aQ@L+m+3OxmKeK1WQv$PgvE zgAk8yFYL9Mu#F^*wfg7~pJ=PWc`Mj$XM?h>FhP4x~6=%R`x`M@x=+L7oQ8R?XH zCW5Z%SZ;^v$+GbNL#UWH+e78Ki*#BS=$mQQvm{&V|;yTV~k4Zw}q+Bzs7GPFBAoM_~)td;qBN z8<5>2XChpI!Y=7%-MF+U7r-~x~+H7yOt!@^pb z?VAxBQ)+l56SW~fk3aUe&X4Oues)88>kMEoB#Pr>pY|5@lB5Xgw*H8Yco?uin+ooR zUlp9F@{cYxqa{a?n%auu*G4GdLM&HX<1h8AB-gsp-1IGM&UQIcG;226!aon`AGw`$ zl1k`o5C%DrDRHn)h(b1$&AK^140?#$pXA+tpf?;&UtSOGMLYjg)~LfcEk1=yP9?_* z!W_;}h>zrVd3osASb+9!WTLP$I4RT&E^7%O1RuYfrY{akLLD5kbwZpxn7E)z{@x$2W(Veqs;nS zG;&M&czd=mKV*u2p}0BUMNIm!HGa@~qFbN*Gu1tSZe@LVbub>E&w5>HU0`u>6-QeF zK4rUtBmVtP5TUVELUJb-ng=7;q`{%QFA=>R#9D5yFRnlVW%F5{C_<&i3S6OfqT>tG zc>QR$ZxF81_S&fO<2qbFqv@?a2$Am5oMcp4P#|3RWyVWhAUkF%bEUefO~ntdMq~~gJiosj!z$_xk&gqT4_R1n##LxA< z#Z>mD;I(SZ56Nw6OYWzfU5UZOHU!7D`3?&Bl>06zp&9KN0TAnY$*pn23_3q0Xp!Z} z1gyR_(oFV)d=|U+@0i-5>_)H40+Q5U@3*7v84-TR2Rndy3cm+X#@Ey1Li#ABpeWgW z8Pk$yKx&CGs_E((RuonyPB0>b0xtCnrcLOEYolLzLEi}nY}@~}&vI=S_@B!w&$N$K zfB>bYS^8h|h^DcKmQ=81|$J>>61ZSs>I8@~@Un34erbn~YLH@j}I5 zWM07}UvC!cSAxGH2#3cElNeX9G)kQ!>5gzuU`OZU;je4rE#M*>Ur^cy3*>Ce7dnxY z>sz7MzSU9@EXLfmQE|me53)`+0pqTE-Uvw`)6YyU#Iv6*l!*s=I~me)3p>AGI3>(MUW>za0& z3^))9+f_sol$iT-?#1}?@f}~g?JHd)g(wY8`;UGcN4h~!=3<{CUc%R&sZbN@m=`38 zK-Ap~_RwOcX?tgU!)#djn1N+1X4E zZu8O)-%$t>=@QjwX3R*zA_d2z&HN*iuFH)9EE%y!FAf?@Vsp048nSFlB_xx|pgZX- zL#ebXy0ggG=*)89_3D9Hn)$YUckt`!3I~nA<+297fDrEUARYVBQaF`7mcr^bQ|lg7 zxQF-d_!s>E9uHu?2NCllwOf#~y{GS2P;Op5@O8Bnx}1FNLzktI8eY(>c_yB2%(&@d z>Q37C6I}G31rLrO<>M1N>fLU~{kmq^_PHbH=lh#WLx`7m4`pMF2A@ip9M4OMRvw%* zn4OHMi+Llah(qx^2}W)vgYbg$Kx<|((4SyPU)q?vxC$w()PyceV!{{4b0{tA=`3^X zrREVV$NONu8-KVkf3~#u8BF)(@!QM7MZC@3Q=mviR8QhEdGf|@TivG$wCeAv8af^g zq(1Pcs*F|FA2@)6FwQjmIQ|wf>-Lt`OL1A|C08q6`R(q1)GAFDB55B5lu7QQ0Rlq* z_bKUWX=Z1}_}7v7uS#axat@muSiL_rkUEc+UpL(%JH@WnuUAXqQQAb}nniVNsg#J$ z$$H_ho%;Qaw(Eyru3CrcYXo7h7T+`N@mmqj46vv-BB_*|Pe$avlS7V?yzu$-87SYb zT!s+<{|Bqrw=3U69qk*>OeLlK)X+fkwFN8Ok5=`vF_NS^F;cMaa$1feRX>Q zY7+_$t;Fpb1jyxsAc3%V&zcUe1$wh(C?`phHWN{U%RA*o<=1psw+z;LSh3y2JH8i_ zVAvoCm@_fucXjT!CTV5~DbfTqQMy9e`KKX)90nmIY0Z`Mbq}luwb!SosVpn2%1k!! zTm8DVYqiBL%KiD)5;58rG{6?vi&9eYAR}zxiLO&I&fAhnDW$0jO*`kVIF}Te+M%Vn z3QIJcm$+)Ea$iK2sOse`iZ<;sEpf0j&H3d%@4cGri>)Xubpzjfd{EF^&IOlWY>nv)!FYV#^sZOba0il~>a)o429S)*+2?@{ya#Yhj@YSbWHY+F=HU z6PL4R9!`@r{Wswt3+=w)32GU~uJ(R=IowQ}8>o_C)c^8&ymB~JNARu-)(8FWgz|Cn zoAJZ;s7y!ghhPEya{8S@OEa; za_W}L#t6x=)X?^0FC612<;8;Pb)dFnEU8Vow>yxIbu#Gn0LMPXYI~YsuOz_}9EPpp zOA@&hJCY)p*n~JRMHg4Z0+Kh-X!cKAqbwzP2@t;id{CzbVn`3F>9ef$q}W`HNd^(= zrnA*=^999@Lvqb^BBq_f%I(l3nV9zd5&rrO>hq*h<91YA!eZ`a)2n!6A#kP4BxsL# z!=A*y7_zKswi8>NgiP%cD#}FC`@KWrH{%z{Op|Wh^v=LXyo`3`0{IWTSyYJDOA9sm zD^KgaQe#6t4#GR*5;Plyw}(vD@S?5F2Vq>$m3l&DC!h+4-7AUBy|OO8t_!I5J(zaM zY}%&0%eN-1?ef3(^K6GU8yPj0{oWK7?5uL5cDW5(SmxLt-W6vFnDhSbslfZg%A&lC zzFNBKhpE?E&(Z;Uc1Z-JLx8Z!`wwkDX`lXsM|PnCy=~C3pzY zbe1DlS0+zF3JM>-sT`yxcX!Y%FRxOTh+p4ZRVYPZDM-diAEtX9{kk?dM~*2Tl+AfP z%1}s^q?T*Kv7&53K{+aCk^_I?%bs(N)^N@yK<=Cu8q-Rv_bqX6Eh=ZKNSpuJTv*dh z^?fkVp?o`Xl@r7DASkG{Wv85j&TuH-F6Ibd!uJWJgHWReIk924)plTU!TJh(>}P`g zZ#S31GSC`Z?NpoR>{`@Ci0CM}@$GOf-IP{_JNVK{j*Ch1sune?742p1l%Hx*XA?;K zmTbbUOyZu#G%apyCNy2HBuJZXIiO5rsI8zSZWl^CJn2)QT5ccxrU4amENYCmCm^Z5 zKOD+k_Jt)uFlS-pY5R6WT{oh`j}^QJ^6wWMC&jB8^{(;1Gn6%EZ{9rGaaQ4vOR!U# zKe~Ly=w3=!O;!hLOs56~5`MoG5cyp(%AH1eFlQ@sq$Yc{ z$84y{VYJT@+&VJ7^J%{(z2cD%1-ovWBpWjQU092XDcvIc85z6Wct_Q~(Vy2rextK$ z-vxw&y?SkRzx}hX2W55lxI$5-z&9~$&uc-A#tBJBfZk5wU^-8+*h{ue4HsPOrjOtR zlu9?B|Hu=DHH(P0sF$Y>@hoUdKld}}pq=Bm-s|*!LH;ur%k}43D+B!CK>)H> zRKUB5n~ST1ou-{Fqm`?f-CsX@HPH<)gG^{ZKYhg}5+_P|!qJwLktmT1U}~jaze6N; zCl(uj?u7U4d3<#oJF%1d03l@Kk#7@|a&d(5z804!{P}Pq_<$6086pIkU5jL?Vj-dB z*6smLtN@BhGckd7sLPD845l6oV&PZ;e^wI?{XXu9)f|XgIT9FUtC8wW(ZYr!!FXat z-nZeWj*q2?{K`-Z!DVz^L!Y}%9#Yp1lV$20yt45>bn#Cfuk--)Py%!j{6p8@Rbc<4 z?ynNCF>?)o{2U2luSbEn*|O5if~r9C83klR*pav@ey%mV1hrE}a;LXY1r(cQ2mkY@ zi=7{0Rl5*H+(LP2Ws3iqrK&@0qYJ^%ex3sArkBk zrZR_`33?5#g{(fE=m6cetnr}PH4$bl2=b;&uT4|O>;*@`Z8f%mm{kbgF4OP8$NK0vTBKseSv zg#VGO=i?o#5dRnF`vo~8~*(Btzhg6bZ|6`OEKOYuh1DKPE0AzRn8FSdX*%_NT z17>CZWOp-~+x7=sSUtOCf(hcfEfGmVsr z-atW_op1LOwEEoth@1P3ytxRDLQ_{prOET1@^1aRWJa_tZwzPn;A856=52D_k==B~ zj0PzT*8w&@_;r*gpw&J#lx>I*uIAwUnUL-6` zNhi8Z2vXhVk+>6u;sH2?CIOp<;=XM)dX#w z1=uWjtYsvOlN7YW8XYb7w_ndQ#oXv$x8*klz;`JmIJRQ1BYMXeD5r&m{EjDL6tSTL?$PRo$b}1q413}E)$QLmySP; zyC@n?V@6^L0&wAaq$G?f=k5h7Q~LH)(`tLAJk}lup>2BR_iuV`W%Wg4dTxbxAqVs{ z^hS?Dyu}zH>~qk0zW1Q^4n!caxr4ysT^>u%G=-s)t-0utU=+Utz<$TzC9olx()#sa7 z7}jEAEcf%VUe!W8-;6yYpRD!YOxFv+(=iQoeXnYr7a)F*w0s_G)|My*K+|+hva1`_#U0tk7U+ z{vO2}nh6fn%tPq0xp&IGrPh(FO8N?&!xiYPtz&Q>$Ogm`ZnCgx*;u>NwnJ(cmM%Jw zZ9J4MO$P>(8xP$-$a=ri=^Tm)4N-w`>$8q@2G9!vQGk1nlXl=Hv;o}$f7MI|cD0z- zpQj5;xcniSOKcoCK9jsw)t!FNlftOR#x76moEbH6?cy2VMx3XfW?H9kV2v<2PK6#A zc=k2Rk`d2%{1OpL>U?MTTT~GEJkxBJ_Cx1@wrN>iz zCOHvrjYPJvlXaOV|1Q14brgtH9W!zy;iWs$+{PL|7c>$L3kD^G&%ccnJY@(%nH&ht z3`gSCy%z?g4fY6QdB^%y5cE658aG)| z8d|(CQ^Ly;KT8~1QA_+ZcKW5Hc3!QUJJBpnxS?vL4qWQfMnd+m*YyER-|zmU)7nh@ zP6i9&Tm@%H!9j%Kl%=H0-L>bBanWdkMYqrW$8UCuZcQKe7QcLZA@h3PCV~S62J(ih zFe91T{1cT(cYMhasi}jGa60c&2SVofmqQX=HEPb_Yv<%WEGdIqQqpJ&zG)9y^ILyx zY?|w~TkCF>zLO47P+2+Y^_+EYtagRJe4}<$y%BgHv5{@Y+JzsoF2=u~>|2@kIVV`K zZWYU9+ZXc;&pzQ|@#*q5>TFW!@NG(GyYPNNl}mhy)_rre&kl#w^jT*7=AIYHF1+oj zV8eCQhBY%eqxt4_D#x+BnDw-;!*`?c{KS0>6ZZV9e6_xWd!Fy|MHjm(;$E6F#oQC< z{dZTgH|}23M}MX_d@jEowr%~fmw{XSQm}kbgTc)Mmv2^_Pj=(~Af(35UvMh2VQZa& zVRdTG+f6s}MQ`$%txK5q;=!Xip-V0bCoKIhmzRA(VY_s5=R3A!u5Qhu9>ECZ!;&*! z$VqhX{Ip8pUt(%R-v_JOQxmS3h`f>D(m7nH^RQxGLgpFf7wkz-+IvsyUgC{^a;ZOl z>cs77ho4R1oc8vv@|^Z_!1KL@Z?`W=OY4=HYMc3K%ADQH&h4qb_NM7j^_rvGCS5YB zt}OL^`$+Xi)m^!R(eFR6pYp^bxcKy|YwdD%PcCkKEmvLhc-iuKbpRen;yZ8Czz1rXQf8U%w9RF3uvTDuuiu*6~&(-|C`~7cv zy+!@QN9*^;{`%W*|9?@b;Q`($b{VlZSlU!ul&hk|51R!yPWQYOzrIlbj) z0r0@(k`4UJn%JZ=7Bpfd{u_6EJ7gc=&B!DI9vW^K^YiBo|s)KkDQH&C|& z1{#233=GlEz`@|uyps5k%7WD5SWxQ>-GE9pZ-*a1rT2j2$lNg9K)QkP7|;kv8z0=X zN7vZV#vPmqRB{ohQ4mGrbD#*k?n%xs0*^$aYyWclw)ktH_8b-l1_2c9^*|B0_QZk$ zY_6UXRc&GdRHqGWf}&bJ0~iqCnFF8^rMW=AW7D43AT4ABwEQ&C2pJU1K||?a?GT?M z8-g_y*srXgS*#Ci3Wu^X@S~WN&I2{6xTG>C6+B&u9tP+KO2YIvFtXdAXhu6;65Sm1 zc~XQq6@@T!kS0;kwWCkGAhf5ILban!!=RghJ`sU1VMZC$1e9qBbp7a~#t8k|l~DcY zW60=6p!dHKMyNDk?Tw=wfZowX81M(h0Bqe}bhFU=jtH~9HljNQ(UU|s1-+|L?{3N literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..332f518184220350bd25b34ed66991d4da84fd07 GIT binary patch literal 36951 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 K?D3!*!~*~dgz-uM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..59d05cdfa325411f43dbe3ce55cfd4712177dbd6 GIT binary patch literal 36881 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 lUg-MK+k*)GD_W7V7;5VDX7)bk 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..42e962eea116098ff4dfa5cd040c29e2bb8c8897 GIT binary patch literal 37170 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%0Dwvu@u~*w5_& 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
      logMGDBwi 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 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 From 3bcf9647a125838b7da028f03a4dff9fd2d78918 Mon Sep 17 00:00:00 2001 From: bruce Date: Wed, 10 Jun 2026 23:56:20 +0800 Subject: [PATCH 109/111] =?UTF-8?q?docs(regulatory-info-package):=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=9D=90=E6=96=99=E5=8C=85=E7=94=9F=E6=88=90?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=86=B3=E7=AD=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/1.需求分析/5.第1章监管信息材料包生成.md | 14 +++-- docs/2.功能设计/5.第1章监管信息材料包生成.md | 31 +++++++--- .../3.数据库设计/5.第1章监管信息材料包生成.md | 15 ++++- docs/4.详细设计/5.第1章监管信息材料包生成.md | 56 +++++++++++++++---- docs/5.开发计划/5.第1章监管信息材料包生成.md | 45 +++++++++------ 5 files changed, 116 insertions(+), 45 deletions(-) diff --git a/docs/1.需求分析/5.第1章监管信息材料包生成.md b/docs/1.需求分析/5.第1章监管信息材料包生成.md index 091caff..0759e35 100644 --- a/docs/1.需求分析/5.第1章监管信息材料包生成.md +++ b/docs/1.需求分析/5.第1章监管信息材料包生成.md @@ -40,10 +40,11 @@ | 6 | 尽量多填 | 对说明书中可识别的产品名称、包装规格、预期用途、组成成分、储存条件、适用仪器、样本类型、检测靶标等字段尽量填入 | | 7 | 缺失项标记 | 系统新填入的缺失项使用 `/`,并设置黄色底色提醒负责人补充 | | 8 | LLM-only 标记 | 代码抽取未取到但 LLM 抽取到的字段,也需要在输出文件中高亮提示人工复核 | -| 9 | doc 能力增强 | `.doc` 文档需要具备与 `.docx` 等价的原始处理能力,不能只依赖预转换作为唯一方案 | -| 10 | zip 主输出 | 生成 `第1章 监管信息(预生成版).zip` 作为主下载入口,单文件作为辅助下载 | -| 11 | 对话唤起提示 | 在对话框底部增加本工作流的唤起提示词 | -| 12 | LLM 意图判断 | 触发判断不能只依赖固定关键词,需要引入 LLM 判断用户是否要生成第1章监管信息材料包 | +| 9 | 模板字段化 | 优先将样例模板整理为 Agent/代码可识别字段模板,使用内容控件 Tag 或稳定占位符,代码只填内容不手改格式 | +| 10 | doc 能力增强 | `.doc` 文档按能力驱动处理:有原生能力时优先原生写入,无原生能力时明确记录并允许 `.docx` 兜底,不静默输出未改写文件 | +| 11 | zip 主输出 | 生成 `第1章 监管信息(预生成版).zip` 作为主下载入口,单文件作为辅助下载 | +| 12 | 对话唤起提示 | 在对话框底部增加本工作流的唤起提示词 | +| 13 | LLM 意图判断 | 触发判断不能只依赖固定关键词,需要引入 LLM 判断用户是否要生成第1章监管信息材料包 | ### 2.2 非本期范围 @@ -444,5 +445,6 @@ | D9 | 需求分析文档新增为 `docs/1.需求分析/5.第1章监管信息材料包生成.md` | | D10 | zip 作为主入口,单文件作为辅助下载 | | D11 | 对话框底部增加工作流唤起提示词 | -| D12 | `.doc` 要实现与 `.docx` 等价能力,不能只依赖转换作为需求唯一方案 | -| D13 | 触发判断需要引入 LLM,不只依赖固定关键词 | +| D12 | 模板优先字段化,使用内容控件 Tag 或稳定占位符服务 Agent/代码填充,行标签定位仅作为兜底 | +| D13 | `.doc` 要按能力驱动实现与 `.docx` 等价能力;原生能力不可用时允许 `.docx` 兜底并明确提示 | +| D14 | 触发判断需要引入 LLM,不只依赖固定关键词 | diff --git a/docs/2.功能设计/5.第1章监管信息材料包生成.md b/docs/2.功能设计/5.第1章监管信息材料包生成.md index 348812b..11d158d 100644 --- a/docs/2.功能设计/5.第1章监管信息材料包生成.md +++ b/docs/2.功能设计/5.第1章监管信息材料包生成.md @@ -27,9 +27,10 @@ | 独立工作流 | 新增 `regulatory_info_package` 批次、节点和卡片 | | 单说明书输入 | 直接从当前对话 active 附件中选择唯一说明书;兼容最近成功文件汇总批次 | | 模板驱动 | 通过 YAML 配置维护 7 个模板、字段映射和生成策略 | +| 模板字段化 | 优先使用 Word 内容控件 Tag 或稳定占位符,让代码只写字段值,最大限度保留原格式 | | 规则 + LLM 并行抽取 | 代码抽取与 LLM 抽取并行,合并后写入模板 | | 待确认高亮 | 系统新填入的 `/`、LLM-only 字段、冲突字段均高亮 | -| `.doc` 等价处理 | 设计 `LegacyWordDocumentService`,提供与 `.docx` 一致的文档操作接口 | +| `.doc` 等价处理 | 设计 `LegacyWordDocumentService`,按能力驱动提供与 `.docx` 一致的文档操作接口;原生能力不可用时明确兜底 | | zip 主输出 | 扩展 `ExportedSummaryFile.ExportType.ZIP`,统一下载权限 | | LLM 意图路由 | 扩展路由 action,支持固定话术和 LLM 语义判断 | @@ -159,7 +160,7 @@ flowchart TD | 工作流状态 | `WorkflowNodeRun`、`WorkflowEvent` | 使用 `workflow_type=regulatory_info_package` | | 模板配置 | YAML | 便于维护 7 个模板和字段映射 | | `.docx` 操作 | `python-docx` | 表格、段落、run、底色和字体可控 | -| `.doc` 操作 | 适配器抽象 | Python 标准库不支持 `.doc` 二进制 Word 写入;设计为 COM/UNO/第三方库适配器 | +| `.doc` 操作 | 适配器抽象 | Python 标准库不支持 `.doc` 二进制 Word 写入;设计为 COM/UNO/第三方库适配器,能力不可用时使用可追溯的 `.docx` 兜底 | | zip 打包 | Python `zipfile` 标准库 | 标准库可满足打包需求 | | Excel 追溯 | `openpyxl` | 复用现有依赖 | | LLM | `review_agent.llm.generate_completion` | 统一模型调用 | @@ -281,10 +282,19 @@ templates: source_file: CH1.9 产品申报前沟通的说明.doc file_format: doc strategy: pre_submission - require_legacy_doc_native: true + 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 配置项说明 | 配置项 | 说明 | @@ -300,7 +310,8 @@ templates: | strategy | 生成策略 | | include_in_zip | 是否进入 zip | | fields | 字段映射与替换目标 | -| require_legacy_doc_native | `.doc` 是否要求原生处理能力 | +| prefer_legacy_doc_native | `.doc` 是否优先尝试原生处理能力 | +| allow_docx_fallback | 原生 `.doc` 能力不可用或失败时是否允许 `.docx` 兜底 | --- @@ -836,7 +847,8 @@ pytest tests/test_application_form_fill_*.py tests/test_file_summary_views.py te | 风险 | 说明 | 建议 | | --- | --- | --- | -| `.doc` 原生写入难度 | Python 标准库不支持 Word `.doc` 完整写入 | 优先调研 Word COM 或 LibreOffice UNO;设计适配器隔离风险 | +| `.doc` 原生写入难度 | Python 标准库不支持 Word `.doc` 完整写入 | 优先调研 Word COM 或 LibreOffice UNO;无原生能力时允许可追溯 `.docx` 兜底 | +| 模板字段化工作量 | 需要先把样例模板整理为代码可识别字段 | 优先覆盖 CH1.4、CH1.5 和声明类关键字段;缺少 Tag 时通过模板审计提前暴露 | | 样例模板文本碎片 | Word run 拆分可能导致简单字符串替换失败 | 文档写入服务需支持跨 run 替换 | | 产品列表结构复杂 | 说明书表格可能存在合并单元格和多规格 | 先覆盖目标说明书结构,再扩展通用表格归一化 | | 标准清单准确性 | 说明书未必包含标准号,知识库候选不能直接作为结论 | 候选全部高亮并进入追溯清单 | @@ -854,7 +866,8 @@ pytest tests/test_application_form_fill_*.py tests/test_file_summary_views.py te | D4 | 输入选择以 active 附件为主,兼容最近成功文件汇总批次 | | D5 | `ExportedSummaryFile.ExportType` 扩展 `zip` | | D6 | 采用 YAML 配置驱动 7 个模板 | -| D7 | `.doc` 通过 `LegacyWordDocumentService` 适配器实现与 `.docx` 等价接口 | -| D8 | 标准候选复用系统已有知识库/RAG,不新增独立 RAG | -| D9 | 前端只扩展现有对话页、工作流卡片、快捷提示和状态轮询 | -| D10 | 本轮先产出功能设计;数据库设计先在本文档中给出,后续可拆成正式数据库设计文档 | +| D7 | 模板字段优先使用内容控件 Tag 或稳定占位符,行标签定位仅作为兜底 | +| D8 | `.doc` 通过 `LegacyWordDocumentService` 适配器实现与 `.docx` 等价接口,原生能力不可用时允许可追溯兜底 | +| D9 | 标准候选复用系统已有知识库/RAG,不新增独立 RAG | +| D10 | 前端只扩展现有对话页、工作流卡片、快捷提示和状态轮询 | +| D11 | 本轮先产出功能设计;数据库设计先在本文档中给出,后续可拆成正式数据库设计文档 | diff --git a/docs/3.数据库设计/5.第1章监管信息材料包生成.md b/docs/3.数据库设计/5.第1章监管信息材料包生成.md index 4e0aba9..476329b 100644 --- a/docs/3.数据库设计/5.第1章监管信息材料包生成.md +++ b/docs/3.数据库设计/5.第1章监管信息材料包生成.md @@ -50,6 +50,8 @@ erDiagram 说明:`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 批次重复初始化节点。 + --- ## 三、表结构设计 @@ -211,6 +213,13 @@ erDiagram | 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 @@ -543,6 +552,7 @@ CREATE INDEX idx_ra_rip_batch_created | 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`,作为辅助下载 | @@ -562,8 +572,9 @@ CREATE INDEX idx_ra_rip_batch_created | 6 | zip 导出 | `ExportedSummaryFile` 支持 `export_type=zip` | | 7 | 下载权限 | 非批次所属用户不能下载 RIP 导出 | | 8 | 节点事件 | `WorkflowNodeRun` 和 `WorkflowEvent` 可通过 `workflow_type=regulatory_info_package` 查询 | -| 9 | 通知记录 | 通知成功、失败和重试次数可落库 | -| 10 | JSON 摘要 | 缺失项、LLM-only、冲突项、风险提示结构符合本文约定 | +| 9 | 节点幂等 | 同一 `workflow_type + workflow_batch_id + node_code` 不会重复创建节点 | +| 10 | 通知记录 | 通知成功、失败和重试次数可落库 | +| 11 | JSON 摘要 | 缺失项、LLM-only、冲突项、风险提示结构符合本文约定 | --- diff --git a/docs/4.详细设计/5.第1章监管信息材料包生成.md b/docs/4.详细设计/5.第1章监管信息材料包生成.md index 2d4c3a8..a7998dd 100644 --- a/docs/4.详细设计/5.第1章监管信息材料包生成.md +++ b/docs/4.详细设计/5.第1章监管信息材料包生成.md @@ -27,11 +27,13 @@ | 独立工作流 | 使用 `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` 兜底 | 优先原生 `.doc` 写入;失败后允许生成 `.docx` 兜底文件 | +| `.doc` 兜底 | 能力驱动:有 Word COM/UNO 时优先原生 `.doc`;无原生能力或原生失败时允许生成 `.docx` 兜底文件 | | zip 只含成功文件 | zip 只打包成功或兜底成功的文件;失败文件不进入 zip | | 高亮规则 | 缺失和 LLM-only 黄底;冲突黄底红字 | | 追溯输出 | 用户下载 Excel;JSON 仅保存到后台 logs 目录 | @@ -91,7 +93,7 @@ review_agent/ | views.py | health、start、status、select-input 接口 | | input_select.py | 根据用户消息、active 附件、文件汇总选择说明书 | | template_config.py | YAML 加载、校验、hash | -| template_repository.py | 定位样例模板、复制到批次目录 | +| template_repository.py | 定位样例模板、复制到批次目录、审计字段 Tag/占位符 | | instruction_extract.py | 说明书段落、章节、表格和组成成分表解析 | | field_extract.py | 规则抽取与 LLM 抽取并行执行,LLM 最多 3 次重试 | | field_merge.py | 合并字段,输出缺失、LLM-only、冲突和高亮决策 | @@ -248,7 +250,8 @@ class TemplateSpec: file_format: str strategy: str include_in_zip: bool - require_legacy_doc_native: bool = False + prefer_legacy_doc_native: bool = False + allow_docx_fallback: bool = True fields: list[dict[str, Any]] = field(default_factory=list) ``` @@ -414,7 +417,31 @@ review_agent/regulatory_info_package/templates/regulatory_info_package_templates | code 唯一 | 防止覆盖产物 | | source_file 存在 | 缺失则配置错误 | | strategy 合法 | 必须命中生成策略 | -| doc 模板标记 | `.doc` 模板需声明 `require_legacy_doc_native` | +| 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/占位符时给出清晰错误或降级说明;不得静默使用会破坏格式的整格重建策略。 --- @@ -504,7 +531,9 @@ class DocumentAdapter(Protocol): | 方法 | 说明 | | --- | --- | | replace_text | 支持段落与表格中的文本替换,需处理 run 拆分 | -| fill_table_cell | 按行标签定位目标单元格 | +| fill_content_control | 按内容控件 Tag 填写文本、日期或复选框 | +| replace_placeholder | 按稳定占位符替换文本,保留占位符所在 run/段落格式 | +| fill_table_cell | 按行标签定位目标单元格,仅作为未字段化模板的兜底 | | replace_table | 重建 CH1.5 产品列表表格 | | apply_highlight | 使用 `w:shd` 设置黄色底色 | | apply_conflict_style | 黄色底色 + 红字 | @@ -528,10 +557,11 @@ class LegacyDocDocumentAdapter: 执行顺序: -1. 优先尝试 `WordComDocAdapter` 原生打开 `.doc` 并保存 `.doc`。 -2. 原生失败时,尝试将 `.doc` 另存为 `.docx`,再交给 `DocxDocumentAdapter`。 -3. 兜底成功时,输出 `CH1.9 产品申报前沟通的说明.docx`。 -4. 原生和兜底均失败时,该文件状态为 `failed`,不进入 zip。 +1. 执行能力探测:Word COM、LibreOffice UNO 或其他可写 `.doc` 能力。 +2. 有原生能力时优先尝试原生打开 `.doc` 并保存 `.doc`。 +3. 无原生能力或原生失败时,尝试生成同语义 `.docx` 兜底文件,再交给 `DocxDocumentAdapter`。 +4. 兜底成功时,输出 `CH1.9 产品申报前沟通的说明.docx`,状态为 `fallback_success`。 +5. 原生和兜底均失败时,该文件状态为 `failed`,不进入 zip。 兜底成功 `adapter_summary.doc`: @@ -693,6 +723,7 @@ class RegulatoryInfoPackageWorkflowExecutor: | --- | --- | | prepare | 确认说明书,或 waiting_user | | template_copy | 复制 7 个模板 | +| template_audit | 审计模板字段 Tag/占位符,记录缺失和降级策略 | | text_extract | 抽取说明书章节和表格 | | field_extract | 规则 + LLM 并行抽取 | | field_merge | 合并字段、高亮决策 | @@ -917,8 +948,8 @@ def notify_completion(batch: RegulatoryInfoPackageBatch, exports: list[ExportedS | --- | --- | | D1 | 详细设计文档路径为 `docs/4.详细设计/5.第1章监管信息材料包生成.md` | | D2 | 模型集中在 `review_agent/models.py`,业务模块为 `review_agent/regulatory_info_package/` | -| D3 | `.doc` 采用 A+C:优先 Word COM 原生处理,同时设计适配器层和能力探测 | -| D4 | `.doc` 原生失败时允许 `.docx` 兜底;兜底文件名为 `CH1.9 产品申报前沟通的说明.docx` | +| D3 | `.doc` 采用能力驱动策略:探测 Word COM/UNO 等原生能力,有能力时优先原生处理 | +| D4 | `.doc` 无原生能力或原生失败时允许 `.docx` 兜底;兜底文件名为 `CH1.9 产品申报前沟通的说明.docx` | | D5 | zip 只包含成功或兜底成功文件,失败文件不进入 zip | | D6 | LLM 最多重试 3 次,失败后使用规则结果继续 | | D7 | 缺失和 LLM-only 黄底,冲突黄底红字 | @@ -928,4 +959,5 @@ def notify_completion(batch: RegulatoryInfoPackageBatch, exports: list[ExportedS | D11 | 追溯 Excel 可下载,JSON 只放后台 logs | | D12 | 本期不新增字段级数据库表 | | D13 | 工作流串行,文档生成节点内部可多线程 | -| D14 | 本轮只产出详细设计,不写代码、不生成迁移 | +| D14 | 模板优先字段化,正式填充路径使用内容控件 Tag 或稳定占位符,行标签定位仅作为兜底 | +| D15 | 本轮只产出详细设计,不写代码、不生成迁移 | diff --git a/docs/5.开发计划/5.第1章监管信息材料包生成.md b/docs/5.开发计划/5.第1章监管信息材料包生成.md index 812aa03..88e071d 100644 --- a/docs/5.开发计划/5.第1章监管信息材料包生成.md +++ b/docs/5.开发计划/5.第1章监管信息材料包生成.md @@ -19,7 +19,9 @@ ## 一、开发计划目标 -本开发计划面向 Codex 执行,目标是把 `regulatory_info_package` 独立工作流按可验证、可回滚、可阶段提交的方式落地。计划以现有自动填表工作流 `application_form_fill` 为主要参考,但保持独立模块、独立批次、独立产物、独立通知和独立前端卡片。 +本开发计划面向 Codex 执行,目标是把 `regulatory_info_package` 独立工作流按可验证、可回滚、可阶段验收的方式落地。计划以现有自动填表工作流 `application_form_fill` 为主要参考,但保持独立模块、独立批次、独立产物、独立通知和独立前端卡片。 + +现状裁决:当前最新代码中尚未存在 `regulatory_info_package` 正式工作流,本计划按“新建正式材料包工作流”执行;不得把该功能并入或改造 `application_form_fill`。 开发完成后,用户可在对话中上传或指定产品说明书,并通过“根据说明书生成第1章监管信息”触发工作流。系统基于 `docs/0.原始材料/第1章 监管信息` 样例模板生成 7 个监管信息文件,以 `第1章 监管信息(预生成版).zip` 作为首位下载入口,同时提供单文件和追溯 Excel 辅助下载。 @@ -32,18 +34,20 @@ | 工作流独立 | 新增 `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 优先原生 `.doc` 写入,失败后允许 `.docx` 兜底 | +| `.doc` 策略 | CH1.9 能力驱动:探测到 Word COM/UNO 时优先原生 `.doc`,无原生能力时明确记录并允许 `.docx` 兜底 | | zip 策略 | zip 只包含成功或兜底成功文件,失败文件不进入 zip | | 高亮策略 | 缺失项 `/` 黄底;LLM-only 黄底;冲突黄底红字 | | 追溯策略 | 用户下载 Excel;JSON 只写后台 logs 目录 | | 前端策略 | 只做最小接入,不单独建设新页面或独立样式体系 | | TDD | 新行为先写失败测试,再实现 | -| Git 提交 | 每阶段验证通过后生成提交摘要并本地提交 | +| Git 提交 | 每阶段验证通过后生成提交摘要;是否本地提交由用户确认 | | 用户变更保护 | 不回滚、不覆盖用户已有未提交变更 | --- @@ -156,7 +160,7 @@ pytest tests/test_file_summary_views.py -k download | 目标 | 生成数据库迁移并覆盖基础模型行为 | | 修改范围 | `review_agent/migrations/`、`tests/` | | 验收标准 | migration 可应用;模型测试覆盖批次号、状态、artifact、通知、zip export type | -| Codex 执行提示 | 请生成迁移并新增 `tests/test_regulatory_info_package_models.py`,优先覆盖模型字段默认值和导出类型。 | +| Codex 执行提示 | 请生成迁移并新增 `tests/test_regulatory_info_package_models.py`,优先覆盖模型字段默认值、导出类型,以及 `WorkflowNodeRun` 在 RIP 批次下的幂等/唯一节点创建。 | ### RIP-1 阶段验证 @@ -182,10 +186,10 @@ pytest tests/test_regulatory_info_package_models.py tests/test_file_summary_view | 项 | 内容 | | --- | --- | -| 目标 | 配置 7 个样例模板、输出文件名、策略和 `.doc` 标记 | +| 目标 | 配置 7 个样例模板、输出文件名、策略、字段 Tag/占位符映射和 `.doc` 标记 | | 修改范围 | `review_agent/regulatory_info_package/templates/regulatory_info_package_templates_v1.yaml` | -| 验收标准 | 7 个模板完整;zip 名称为 `第1章 监管信息(预生成版).zip` | -| Codex 执行提示 | 请按详细设计录入模板配置,source_dir 指向样例目录,CH1.9 必须声明 `require_legacy_doc_native: true`。 | +| 验收标准 | 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 实现配置加载、模板仓库和存储目录 @@ -193,8 +197,17 @@ pytest tests/test_regulatory_info_package_models.py tests/test_file_summary_view | --- | --- | | 目标 | 实现 YAML 加载校验、模板复制、批次目录创建、路径安全检查 | | 修改范围 | `template_config.py`、`template_repository.py`、`storage.py` | -| 验收标准 | 配置错误可返回清晰错误;模板只复制到批次目录;不写原始材料目录 | -| Codex 执行提示 | 请实现配置加载和模板复制服务,所有路径必须校验位于批次工作目录内,原始模板目录只读。 | +| 验收标准 | 配置错误可返回清晰错误;模板只复制到批次目录;不写原始材料目录;能审计模板是否包含所需 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 阶段验证 @@ -380,8 +393,8 @@ pytest tests/test_regulatory_info_package_docx_writer.py tests/test_regulatory_i | --- | --- | | 目标 | 探测 Word COM、LibreOffice UNO 或可用兜底能力 | | 修改范围 | `services/legacy_doc_document.py` | -| 验收标准 | 当前环境无原生能力时返回清晰 capability,不崩溃 | -| Codex 执行提示 | 请先实现能力探测和接口骨架,Windows Word COM 可作为优先实现;不可用时进入 docx 兜底。 | +| 验收标准 | 当前环境无原生能力时返回清晰 capability,不崩溃;测试不要求本机必须安装 Word 或 LibreOffice | +| Codex 执行提示 | 请先实现能力探测和接口骨架,Windows Word COM/LibreOffice UNO 可作为原生能力;不可用时明确进入 docx 兜底。 | ### RIP-7-002 实现 CH1.9 原生写入与 docx 兜底 @@ -389,8 +402,8 @@ pytest tests/test_regulatory_info_package_docx_writer.py tests/test_regulatory_i | --- | --- | | 目标 | CH1.9 优先 `.doc` 输出,失败时生成同语义 `.docx` | | 修改范围 | `legacy_doc_document.py`、`package_generate.py` | -| 验收标准 | 原生成功状态 success;兜底成功状态 fallback_success;两者失败不进入 zip | -| Codex 执行提示 | 请把原生失败和兜底失败都写入 `adapter_summary` 和 `risk_notes`,不要静默转换。 | +| 验收标准 | 有原生能力时原生成功状态 success;无原生能力或原生失败但兜底成功时状态 fallback_success;两者失败不进入 zip | +| Codex 执行提示 | 请把能力探测、原生失败和兜底失败都写入 `adapter_summary` 和 `risk_notes`,不要静默转换。 | ### RIP-7-003 补充 doc 适配器测试 @@ -565,9 +578,9 @@ pytest tests/test_regulatory_info_package_models.py tests/test_regulatory_info_p | 用户变更保护 | 不得回滚或覆盖用户已有未提交变更 | | 过程日志 | 每阶段记录关键命令结果和既有失败 | | 阶段验证 | 每阶段完成后运行对应验证命令 | -| 阶段提交 | 每阶段验证通过后生成提交摘要并本地提交 | +| 阶段提交 | 每阶段验证通过后生成提交摘要;是否执行 `git commit` 由用户确认 | | 回归保护 | 文件汇总、法规核查、自动填表现有测试不得回归 | -| doc 风险隔离 | `.doc` 原生处理失败不得阻断其他 6 个 docx 文件生成 | +| doc 风险隔离 | `.doc` 原生能力不可用或原生处理失败不得阻断其他 6 个 docx 文件生成 | | 外部依赖隔离 | LLM、通知、Word COM 均需可 mock,测试不依赖真实外部服务 | | 下载安全 | 所有导出下载必须通过所属用户权限校验 | @@ -588,7 +601,7 @@ pytest tests/test_regulatory_info_package_models.py tests/test_regulatory_info_p 5. 不回滚、不覆盖用户已有未提交变更。 6. LLM、通知、Word COM 等外部能力必须可 mock。 7. 每阶段完成后运行该阶段验证命令。 -8. 验证通过后生成提交摘要并本地提交。 +8. 验证通过后生成提交摘要,是否本地提交等待用户确认。 9. 最后使用 docs/0.原始材料/目标产品说明书.docx 做端到端验收。 ``` From 1bf863437360ab29df3c9f17e89e0f4429b40a10 Mon Sep 17 00:00:00 2001 From: bruce Date: Wed, 10 Jun 2026 23:56:40 +0800 Subject: [PATCH 110/111] =?UTF-8?q?feat(regulatory-info-package):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=9B=AE=E5=BD=95=E9=A1=B5=E7=A0=81=E4=B8=8E?= =?UTF-8?q?=E7=BB=84=E6=88=90=E6=88=90=E5=88=86=E5=A1=AB=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/docx_document.py | 201 ++++++++++++------ .../services/field_extract.py | 36 ++++ .../services/package_generate.py | 122 ++++++++++- .../services/template_config.py | 5 +- .../clean/CH1.11.1 符合标准的清单.docx | Bin 37136 -> 44082 bytes .../templates/clean/CH1.11.5 真实性声明.docx | Bin 36951 -> 53461 bytes .../templates/clean/CH1.11.6 符合性声明.docx | Bin 36881 -> 41242 bytes .../clean/CH1.2 监管信息目录 - 页码版.docx | Bin 0 -> 40037 bytes .../templates/clean/CH1.2 监管信息目录.docx | Bin 37335 -> 0 bytes .../clean/CH1.4 申请表 - 复选框调整版.docx | Bin 0 -> 58034 bytes .../templates/clean/CH1.4 申请表.docx | Bin 37170 -> 0 bytes .../templates/clean/CH1.5 产品列表.docx | Bin 37224 -> 41629 bytes .../regulatory_info_package_templates_v1.yaml | 14 +- 13 files changed, 296 insertions(+), 82 deletions(-) create mode 100644 review_agent/regulatory_info_package/templates/clean/CH1.2 监管信息目录 - 页码版.docx delete mode 100644 review_agent/regulatory_info_package/templates/clean/CH1.2 监管信息目录.docx create mode 100644 review_agent/regulatory_info_package/templates/clean/CH1.4 申请表 - 复选框调整版.docx delete mode 100644 review_agent/regulatory_info_package/templates/clean/CH1.4 申请表.docx diff --git a/review_agent/regulatory_info_package/services/docx_document.py b/review_agent/regulatory_info_package/services/docx_document.py index e42d49e..c7b5629 100644 --- a/review_agent/regulatory_info_package/services/docx_document.py +++ b/review_agent/regulatory_info_package/services/docx_document.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import re from pathlib import Path @@ -20,6 +21,7 @@ def write_docx_from_template( 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) @@ -32,61 +34,19 @@ def write_docx_from_template( highlight_count = 0 missing_count = 0 llm_only_count = 0 - highlight_count, missing_count, llm_only_count = _insert_prefill_block(document, merged_fields) - highlight_count += _apply_known_template_replacements(document, merged_fields) + 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.add_page_break() - heading = document.add_paragraph() - heading_run = heading.add_run("预生成字段") - heading_run.bold = True - table = document.add_table(rows=1, cols=4) - table.rows[0].cells[0].text = "字段" - table.rows[0].cells[1].text = "值" - table.rows[0].cells[2].text = "来源" - table.rows[0].cells[3].text = "待确认" - for field in merged_fields.values(): - cells = table.add_row().cells - cells[0].text = field.label - cells[1].text = field.value - cells[2].text = field.source - cells[3].text = "是" if field.needs_review else "否" - 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 document.save(output) return highlight_count, missing_count, llm_only_count -def _insert_prefill_block(document, merged_fields: dict[str, MergedField]) -> tuple[int, int, int]: - first = document.paragraphs[0] if document.paragraphs else document.add_paragraph() - marker = first.insert_paragraph_before("【预生成版】以下字段由系统根据说明书预填,黄色或红色标记项请人工复核。") - marker.runs[0].bold = True - highlight_count = 0 - missing_count = 0 - llm_only_count = 0 - for field in merged_fields.values(): - paragraph = marker.insert_paragraph_before("") - run = paragraph.add_run(f"{field.label}:{field.value}") - if field.highlight_reason != "none": - run.font.highlight_color = WD_COLOR_INDEX.YELLOW - highlight_count += 1 - if field.highlight_reason == "conflict": - run.font.color.rgb = RGBColor(255, 0, 0) - 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 _replace_paragraph_text(paragraph, text: str, field: MergedField) -> None: for run in paragraph.runs: run.text = "" @@ -97,6 +57,20 @@ def _replace_paragraph_text(paragraph, text: str, field: MergedField) -> None: 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], @@ -141,19 +115,26 @@ def _iter_paragraphs(document): yield from cell.paragraphs -def _apply_known_template_replacements(document, merged_fields: dict[str, MergedField]) -> int: +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, - "2023年09月20日": today, - "2023 年 10 月": today[:8], - } + }) changed = 0 for paragraph in document.paragraphs: changed += _replace_text_in_paragraph(paragraph, replacements, merged_fields) @@ -208,6 +189,8 @@ def _replace_text_in_paragraph(paragraph, replacements: dict[str, str], merged_f 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( @@ -215,27 +198,38 @@ def _rebuild_product_list_table(document, merged_fields: dict[str, MergedField]) 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 - if target is None: - return - while len(target.rows) > 1: - target._tbl.remove(target.rows[-1]._tr) - specs = [item.strip() for item in package_specification.replace(";", ";").split(";") if item.strip()] - if not specs: - specs = ["/"] - for spec 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") + 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: @@ -247,3 +241,82 @@ def _field_value(merged_fields: dict[str, MergedField], key: str) -> str: 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 index 4f0eb65..d2342d3 100644 --- a/review_agent/regulatory_info_package/services/field_extract.py +++ b/review_agent/regulatory_info_package/services/field_extract.py @@ -13,6 +13,11 @@ from review_agent.regulatory_info_package.schemas import InstructionExtractResul 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]+)"), @@ -47,6 +52,24 @@ def extract_fields_by_rules(instruction: InstructionExtractResult) -> dict[str, "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 @@ -133,3 +156,16 @@ def _parse_json_object(raw: str) -> dict: 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/package_generate.py b/review_agent/regulatory_info_package/services/package_generate.py index 5fa0030..6b11ccc 100644 --- a/review_agent/regulatory_info_package/services/package_generate.py +++ b/review_agent/regulatory_info_package/services/package_generate.py @@ -1,7 +1,10 @@ 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 @@ -18,9 +21,16 @@ def generate_package_documents( merged_fields: dict[str, MergedField], ) -> list[GeneratedFileResult]: specs = template_specs(config) - with ThreadPoolExecutor(max_workers=min(4, len(specs) or 1)) as executor: - futures = [executor.submit(_generate_one, batch, config, spec, merged_fields) for spec in specs] - return [future.result() for future in as_completed(futures)] + 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( @@ -28,6 +38,8 @@ def _generate_one( 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) @@ -44,6 +56,7 @@ def _generate_one( output_path, merged_fields, template_code=spec.code, + directory_page_numbers=directory_page_numbers, ) actual_path = output_path actual_format = "docx" @@ -68,3 +81,106 @@ def _generate_one( 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/template_config.py b/review_agent/regulatory_info_package/services/template_config.py index e700859..42475f9 100644 --- a/review_agent/regulatory_info_package/services/template_config.py +++ b/review_agent/regulatory_info_package/services/template_config.py @@ -32,8 +32,8 @@ def validate_template_config(config: dict) -> list[str]: if not source_dir.exists(): errors.append(f"模板源目录不存在:{source_dir}") templates = config.get("templates") or [] - if len(templates) != 7: - errors.append("第1章监管信息模板配置必须包含 7 个模板。") + if len(templates) != 6: + errors.append("第1章监管信息模板配置必须包含 6 个模板。") seen: set[str] = set() for template in templates: code = str(template.get("code") or "") @@ -51,4 +51,3 @@ def validate_template_config(config: dict) -> list[str]: if not output_name: errors.append(f"模板 {code} 缺少 output_name。") return errors - 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 index c92ea895b6d8aba9eab70dffd2fd71a2a6b5fd40..dc874a5af56ce8f2e0b0c58a5272c57ee8051ec3 100644 GIT binary patch literal 44082 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 D0>kQ# literal 37136 zcmagEWmufawl<6hcMDDk?(Xgc3ogN38+RJFpn>4-?(Pl=njis!JHf4iM&HiNo_%Ka zKIi-XG#6EC-Lk5>s``0K^&Ko6E)*0LA{1r8*oSJh;!nxYP*7tCP*6CKR((l;qnm}J zn~{dMlZC4RiyJ`di%crdLTYs|U2H_sR78-p?sS^W(EbYQKu%&*5s|75lmb|;tpD5)Qwux+XshG3u*pZl5Y>do;3I1bzw_pIQ8{U- z9e$zRX0+ZNQ}uY72=IIZmJF26d0J$l(aN^VMD7UUON%l-*OUo2Zb}?-t@oH@Y;>{G zbZ%JgFGlwoK>=XS>^StXc#dmx9wIc9$6TxHtzp}S?VJGYLTTz|4`6e&UmOMVj+V6; zt-JJe<23wj4nyRKd~PMtvyS?L>1i7&4L&1eaCF62`-R>P-rmv|$G;T;FGd>P&dn-0 zOlSsv!3gKp6s=e56pv5&%E{0B;(RFi#vhays=vf9rR?7&Wh6`&BsS!GcY*$}X9E9| z@D4So=2q^pyJXjWIk><~ktu~<|0$WY2&?y#$%F4bN@?Y5DUtrih0@hs(Xrb%1rNAN zhES^i7)5WIcVc&t(OZLof6Ma z6~q*zt{_S;jjd2!`;2H16}p%O%`K0m7KI4o8qTmjedFnO@fry!BL)XK^s>L0zgKwh5KV^b^DIJ=f^wI7B@NLK(WIFCoJ`JWpj1{z9MvaXPj$E`Tgm* ziiGwo{EquhRcrmq3zmZrNfmWNuea9-lU>+Dd{#|DrQh4OW%rohQcY_6Oj)|Gg}e`N znkGADt4}@CYmQ&^NBsI-pXcnK1&RzuQMZ9i^Y4%XVL=LH4lq-70XVs`ngLuaeoyl3 z#Bt?8HcaVTKj}Fot*9$ZL}_^j=);c+a=1Z@?KfQ9O7`P9Jd^9&MGks-j2>j`f}>YX zBCEX3t*~ZciDBdspU#5JKceX3JujQ$uRP>PGw@d@hYpH&FZ$DL^&Uz|A!mK1G?`rdE=FAfb+>v=pu-mPpq@g+{T9OaCh!$V73<5N>v{5-G%G(;m))s76m^>tIcvooKlQZP~4^_-& z_mi?JvEY2f6@8C2z)*4h=-k}a3bQM9ps1U98)qHPQ_DCXZK??{Yc(Hz)Kl0h0xA3R z?e&EXd*)|(+*0u@IQb5_8K$1ef`i6@7uo06izJM9ycckUhgRr@^s}Cl&yNF_#Qz+Z z>*ZyFBgnYe!$Cn|{(W3boSgm`7L9SoWj4&N2Szvpw~scj#NQJp2LBNMF1bonmzdQ$ zH}*#16I?s{7SJCYiN_z#3W^eJ6ntnJdsq;+eAT3Et%)}dF8HGAyo%gh`*Cz{d2;`a zD_jM9IiA)qmVvX;^jP&g0yC0Zy0&V~nD3iL(}@eRS5B@fjMHvE(Z$x9d`mi&o&3H% zT8K#>d(__zohPd{FRrD%XH)rD3HwxQUFlsmEzB*u4La0KNk&7LfiZdFgd6v+w4qyy zlp#=}AZ9o2KA*6LvqkZB12vfc!pgI9&?gX@r4lnattSgI(6Yes0_rjS0Ek`ptU(VuGX?@KP|87S+=;^lG^VR=w* zh5_55cLB8UpY4PRRb@T&w#aX-iJmZ7E*#kRH^A<~D2JX1e89FwB(l^KB>ShYG2>)G zZT9P_$s&{5>u&`kvIsPN?NvUd4lT=z7OTj1cV)6ZvXY%6WrDAWU*gc^z*h?77)tR{E1>8=rbOhNs$6ji!IDPOdP``@z=TmL_Qn1`BrS zbK0=t!cIC$@xq<}e8^Uc8TKa_xB(PW0)km3%9Y>(rxh;Jp;A&5&M_MUMYBV?Cd1ac zayL3oT!Zh{u@Sk|Lej!ffC*|`ajv5$fS{d7ag4<@JI?RB}avvjkYy5=> zvp+@e6xX;zPKSHEMT)m?jF#=hv}0I~av)8?3yvM0Le*N(Lnxh$R~tJvgD+S~LM?nk zHZkHF*Yc-W8unV#O1>j&G{Tf6s*8O;)v(70X3qxcT&ptJNlYi~`*29>*GDfYrEFtq zn8>%L*vJpABZ@M-M*F)(ukl))IUHCye!4_h|M>ciC8-z=1wFVo7_a)fwaufsIezAs zFxG?+lvLtS4voejEV>+I5AzPVQuSD()japXQ5ckrTc-+jYpTw)k-8J56fFf;g{|cH zGNWQCr?gnU7BRlFi|psqQ5!a{@z68Qw8&_VR#j8=f$Pnr^++5`5S{L4vCj;tO*UvE z<^4BQfbEtSr+NJ+r#f)Y%MNVqtvs9N7pSin7 z*whYt6vf?V0XL#g2g6e(FW{D5Q1Z+0)pL@SZ{@m=oqmX~(S0i~!fi`esi=1)}epZiDh1!>mh36@niBEh_rSXr=27o6Z7r1dBcxDH9I$ir2|D5drc;cv73~c~@*;X_ zs$$<9#8A!?8*^seH zlM~M=^UVc?u@tC$@U#EqSXe*8fXC8`e(>_;>Wx(C_SDl$ae|r`ad$tjXqGemvrc<* z)~xKi{I?Oqj6xjGara3$x8J7EDXE)g*O6WtKBlJb>eF-9UPS z=@WkSyxlH}oPwclPj<$MhDVSmY>i<|(5}|HY+SMx;9;)oE8Cm*Yr!TaprmWEboFK? z?{RF49VS+jRa$V}TrEd>a^-95YpFJZF zgeBpYI7YudE}-dTkf|;93zPYocq6&HuD@!|B)Ju|eaqvYOY1N@d87VgO^}eWNgJWb=AJYXQfBv`gZD}2IRNR z`Ho`kcr~#_2Quo3;SIccFuGFT6Vps0a&z2#I^SGO*0f!ASJ`cdu4cIQZzwlv1rK1s z)i2H)8&Gjm8*iQMe*@i_lLS1yGDNm)nkSJg*$sMB`Vfn41gCYI1v|)jXKXXJZTZ6t zO3JfWGF6hJxGqb56ZW!{Gg_RFx* z@mH-~-MsBBT>n_i4GfaDWE%W?dkYGU9(NoPLZL>d9HA0DNKAaYbL!7Hl{jaQzkMUH zjz2L9pr#Zf*HqKgQm9C55FE(CSEb+8&Q8*>czVhW=(0Zp8j;6kKAo=e4)&t;wCkT~ zJ(NzKUUdS1PlSGg;Js5Xz0;hOTKxZ1P^Q=PeZ_ zuj=pXRte{ijA?kewrSM!eVAV!_woEF`r@Pm7{t2m+`ifadA&ZV{9m=r$IUV|Jk9ef330?2?OZv$uohF?%_I%h9Vk37e>Z5zRa3TUsqmO{;Vp)EB-wn^v`;pK$7>CYN=#{&g-kJ80)|^X>vcgoD<5Ip;~aSX@BkbJWWdDqnlm? z5G#Y4$gLBp`+SY4GgTDm9j(#nCwPKGSsRsbU55c*9C3;*Fdfl|5m8$s`c!A+uPf@$ z7slGv8E+9r5b*T8KWG_%_;kJce-i;%|5c6t2m2 zTzJ75>Pd-d``LS)8F^;_}sEn(>~uV?|Ja|Xp);&i3~~kN7DO6&H~*% zdag%LY=&u(4K=_~!Lu5`f(@$VT)H-?U0P%^RY+4mqv-oO0OY?trl%p>*DnTl6LO2M zL>!fu-7R2<7)m-;n-e{&^zawd}X zUZxpxmN*~WySn;yI_R3k#TeF3Qmh^OGPvheL6&tzrx}8>^lkT!I^djqK_#;_HQE;L zTcjQ1$GQq>J~6CM&{AY3MI1eSPn~@iRK(FMFFF=}K@YiQF#zp&)@+-ugw~H?3pxTG z1P>?A=f4~nv&cqfu>9~SZxCn1{YZ`Ix4?ya-uZ7k4(=Gt2W(& zHD^ObOCRglIoIVEbsV;mFpGF#6%f@jk*VSO_obXE50eT*nVNOgBocnQ(NiK?t>N|= zAzqaP;)Z&wiy{B)qNhM{SgXn|ltWabV#E%1N^A|J$l5UwjQ@mLl+#riwm}Vm*5&)E zFNUdniH{kY)CK>AS>h5uAvmE6z9{c^r!}M#|F2HC&9?w(@qfkFG!s#?z_EIxd>N2m zgZT^lgewt}N&$(Z|07iof))nB{YUDbRB+|AXSS4H|3Pr}o(j@}{D+E^E_n0&|C0DW zPzgSlHTc1)kf^^4^B{)kQNx6I#ckSQra)o-4~fhah_v1)h<~V_OY&^S?D`$`@1`PG z$)+if&K?}Y904i4d5Rt~EqDJjFm!-!fFAje&g4D?Wa*$(+Rq7PZ7p6s>jCXJ`il;( zBvLwad(NFpqvQ6^OfkYN`YDfR!>bP<_jUuvVPEmb!G#HreCtK8EB(8X+1%Pqui9Vp zA}ag4dtVDS?_$?Q4O&4`P8?*9ozXFc$C_o~yA18!^UD`a0a4jh0MO@|OB%jjH&1ozDfF)`VMhuLUb8Tx44U|x zFv#xpk_6DxS(=_P-hDaj*~aWu|)3yLMbJNXfi zC55g1wq$N;i1U2@Yfsfet!UUKIZMDJX2$bf0w`$|RQg0Y7swgay?w}Yv3vt4{bB&T z^Y_L3?2-0yvTyt)#U3HfaK{m0%I~t}HD8C{GU}t(^V!y;R3##;;pvQrY(JOBF5&C{1RGSn4F<6dLVx72HuXb z_HY@#R2;={*ZBPp-r6p&Yj)nWB~H3i6VE2wOB#GV3}>Vlh#GXqel;sk0!`bF#q8ie z@?+kLXaA*TQ=F>iRVZgp)8h$XjX#pSl%}|?>}+$E zdB3_JPV_M&MJYw}gw-ILkk`$-d_}m}B^6;k&!F-aObZME7eH;>HQiN4UfKlJGWl1$ z?$9`-Mzc>QT9n|Zo9Bk*)nZQNDmAcGCjOe`aW;Vzwz!YS@9mpaYifMtRm5Q|Oair+ zvjCo@3_;mym5I$r+LaEF=DRx;l9yg@7_k*)RFXK03;+Xmj7Q7UgUI@$p))}e^hQT4ZerA<3p|5 z+}I0_!SjAD$iQj=6vbDdUqYZi~5KyuC%85!PdjK z;&9lqN|w$@Q_F0_Xuo3_mEC-fB>W3^#Ib9tHO{!~oqGf_xwsZ)0J6nYRv=hySbt`= z#NTk~ylcfjE-s}*<;Rb0<-+fr_^x&{Jl!K!t<1b>=n9mQRMUnNZ!a3PX+PcP`17T4 zU7ugIo_I{&Jaq0fTh2Qb2XBh@^!9L%!XM4R(xOhW@{pm~YwApv1>@jFF=U{lw=f)c zXkMs43e)Q2e6&>@Wm= zs6qF&d^)7|DgIJeLoP;Hf$G4<10{OQ=H@?JE)b#PCyVmtWqL_tET77EP{+7QWex1$1x~+Qlai1XhXrlH{u&w4j7C3{6tei=Pxjc3@3g%r;65){O@oqtx zqo*%N$&E%gvjt4gOpL0AC5EY?b}C2+ea43@f;dZl$9zHU5S+Zc4epJ#XZhT;RW|pL z>Mdhjhw<=ztEK$UDdy(vXmi0j)nesnf^=zl<6NO6+>Po|jdlunrK4rbnZ9UvQ_MwwEWnv;?KPl~SJ=gkywc_mR z9>athCcBYP|H>S33D!lw#ZzQg1dz1T@FA3aF&RFu$i_mP{OG4N*NgE2!Dn(MTQuPi zDTzTk8>%rDUi0bjVLHL0;bcwn?-T6^VSm>mO5DDj{mM z8_L)WFUINF0rK0sn>|6R@M)m*5eRP&Aco-zx5N+m-nVJJ*C4`~+CIPfv9{Kfz`-J{ zJQ!CKRL}cB!N7e8JGJJPc8FP1B}1$m%hVj$`r+Bv*@h!WTf76B|J|#llLPFxML9Rr z!+TV-Tr5}zmd@%5Zqx#@d1Xll$#;>^B?>dKtb8h>6_1ASg~dT1tYW~==S?D6z0q-k zW*T{u;>ZqbE$KA=oMO3n!|lf^Mgoq3B3(;iDLTE{4|jmNcv0LrfJaT4(_9r@dJL!INP%*$_cv zqq*D32j6xwinorKkS5xqXOWOQ5)9x0z)6)1o1s&d~1(Po%&i^a2Ze$VJzuuPa^ zRzr9@RI<9lvVmsZ*cr$q;b5*S;W#3OGkpWIJ}sW~n6+ee99}^oeLeCMW>gGtob8lR z*jjGZ*)sT0Qzm(!wDFEz4yKkQ}bjj z+7BPsRPl-;*K`CyuMc9oO^jTxc|1x}x{YZmI^P{Q2;@%7s;`9v<9O}Xg|4|}ZNqm# zJH!zrNFT&9y*rpBaoW1?;WZ7$5p->CJwQ_z|9DXCHNu0_U5~NygQPna#!4Wk9_L&2 z+Xd)rp4V+t6I;S`d_mgi?+%nk`3?JszwLQJ>xQmK||^p^&d zA*)FNsMxQ0IIy~x2BSE$8*#p&33)fP+ym9ldx`}c~urxSYY{3 zjbr<8zRJ>hX8OXi!7!EHEdxmV4O2tt&V(5R(K+KH=Z#b(C(GV3%;cS0`u3Rcbh}WjW#}& zUG~Vluo}WA>tukMQ?)e7fv(Yw7)w;<5Aw*YiIW*EuMr2dt`(!Ebu}E*B+|5+&w|#J z)Wt+ps&zUd9bC00Td)r}JBkAdki8_@xgriW)j$OX4Wx-D8mg?d3yMUb0pdxv_h+Ue zP6nm5W!eo!p=**2=<3$P$k-hQ*Y3JU#Mv}2;vK~?|Q!XjPgD!m}ir=$#QR3zs&iAnR z@P-9fALVPQlS*~-S6b>9U%v*a^`XEEo!Svz||{7SNyAl0%`iK|>HBl;*Qj%9`i#r+xF$l&D1I*jE;rVJ*P2S>{yW6gYQnMp zH9a%r;4B&uZCR{@1)zulTxe@<=^Nwzy*Z$kv4$#^s@I3Fbj2QEjcYdkj&^A=g8}{O z!}nISE(|a;ODMSLOJMPZH$-^Q^miGk2m&Tjzp2b0`x=EkZYPd4m7)Xx&GUxhFCI@K zl@3Hi(b$NOf$*8?yucucB{7Jl!q3VW1qBgbzfUs59AdB8YQ_s-SmwXUGsRLJ^~!U~ zdy^M_yB_~lu5n|w1yQagsVQ4gd<5hVa3kgSbuz!ND3KPsjMXa784=Mo!buTL zMEoL{#RhRjt}G|r7d-jP>COMJp{p1DlWn@Z@c)ZVN_+&RD@wFIE=yQe&|h;F;z*`a zL&Yq&vKCQJq%>-J{yp4I_Iwr#%)qp5$s1#56u#_v9btwl+Ps6dhRmB^_BISYge0qv zIe{6Wt)@6lMDZuZZ}Mza#L%{b27*2Y60HS@(ojtXt1Q2vSW=$x;DCQUo(sREY~LC* ztIf>}lL%;10CC;46ZygOdk?EF z#k}J8+I(rRowfUpR+kgBdQ=3`iVEa6Bz@81*W;)g*Ugr6-jec_QC9dRq3&KBS)bpI z<5Yb8YVirC9#<-bBWD@`<QvH>{C&3fgzb(w2gsbP3?T zkfLM>vvo{QgpsU5$Igqhc)V$3!AuZLqxUt}&ecWb?|_a|W|CRQA`x2BThJ*=g%6=IfIqViTtZLqZlkOfAxbhWyiBtCeZyI&y z=RR_Dqbtood{3WlLVP2M44HmBvZIX-+OKQeMEt`yvjW1GASMNb$e@$xPxw+yC5%kr zZyK||R5=(yE^>IZ*!cWSB`0sjZ~Hf)wEi*9NGO1v1C(;nyve%mff%^gC}T^_e$`I znt5gRBTFlW&@mn88ZIRkW*mm>Q#isxQQi(sJCZ=EO*2fsNlPVZkfupC=WCay77NA_gN_27^6iZ4*-+jn;JXHi`((txn!@QkSIpH@&(^q& z#F;4G>>vV{j}JbYBz6e2)(brCxDJ61<-C0~E%NqBiq8tO7fRcT z;bgT#e0J}dot3w;RuNtg@q=c&IrgibP1U;%_G|Cz-9 z-%0i?fO6hc+9~<0Bt9z$)sVS^aR^{5OJ{_3$|mdUx9wy;E9MGnNPpupMF?LhutR$< zD_Is==TH_JbN}t{b~}1`(*~Y8QbA{Brfr;kt|{|_3x%vk{CR9h|B^$3c$T{8Dx>Sv zSO_r_D1roN&Z3n!jm@@-?!PEO3dQj$?=t47MN`(_!Fvc z1!M&s9N8m*u58zS5x?x3v$+>WVqw^2H47g(Z&c3`RVd&HLU#mCXfTp(n^90s$4rnS z3DKoOnN}R=W-O1dU&j%ad2-2ZEg5b+OjMLO=D~rq=8chBQ(f>=`iSXqnEJ!0+bv_# zb(%IP4f&b+u?J0}62dys-u;Zp78%5b1;rt}d!)!ZS;|ys)LxFDE^Dnoc-MBq(^v<$ zW|w4)#KQCybTl?pfQE4ap-v%0fA^bO=MU->2(^&TAE)mrP>W+7+|?h{RQeF=<$A0? zs8d+db=>|h>RW1M$Z`Aea&KJt5dNJiKoc#+Df7nM1gXmmqaS;xnR?0KC0&Q4U$V+j?8&y(Ld(C%Yb7D%nF5%9kjLgj$T$B_-!Rjj zU5|Sy|02gjcsG{XF`(Vn5nGUeUy>vumOB>f_v`8M{uFKEq`x%#yXL|1-Ga2X7IyV$ zFaGE;H#Hyis#U*>sv^KIx|r*95CKi?`&4}f*^}MjiM?igZzFevIlnq62jSaC+gb~s z!?I4|wVsL-|GUKb(3*1E`4jmtViQ47{c#I}BF%_h5UOGsL2&UJN)VmyE4uVPOG~hB zZ8hl(McwR~Ar``WPTebFjLgHgR*B9k*NRj=G{%DJ-0OVeNjVh)hnL~%O*apT*2*)7 z+L+(DFqJCr1{Aw_QNK5#f8z?;L%(!2(kG=7%PU>)c110)Y1rc>6PTBueY>4wogu@e z)aOxR+vA);BdqB@w~JP4b8>4llT|WbOHp7$k)gH5IIebuVcrMhTV@PF;3+1|X|MUPY7C-FwzX@5ukit<pWE8FfurgEgm&A3$=INgKT<li^PiS zn-0GXPHU83GYuCmGb0j%Y?HK*Z8Duq3>A?Tg0=M+VVk5?!GvWaLbK{S$8Ysn<4FQQ?fT+jx5BngyZU?lV=d>e&) z7X@n>tCx8BYn6x#mH;H}x4E>-&h0!2$# zbS0-W6AW9>s{|NZb(93yUoenk#sG?wc%qlMp{mMxu%31#`Zj#pD5jyY&yr9Y27e{B z?%%|rCPKuGP=peEG?G-thn+Twc#3>|lLtb9qW}HDp8)w_uLGeNi#Hq*6v2bwS?L^9 zPT33ty-_#eZwdlG@m?;i`_y+;&4W=ShoFwcaenJ-6c4822)-lrGRnDOk9sX1?_!STV3L zn6%K*zjfW|mE3-4OQ-(f@@mw&d*C{Mzd7IjX(K#`P>{IziFZXr*!6Zf8QzaTS+@^M zae|uLH=#$(b?!ZAE&K3VeTE6Le(`r?7s3uPVtyFIENd&|&RV#?M(%zBD)Xnn8t;+4 zKGcQG)w<878x0fq9LBzj1iJ@JsAc=L-jjbRy#zmum}b zS9;ib_m*{=`yXW}Z_Tv%bXDXhrs164P1J_SQ0zsRrj|tnj9!{v> z6$g%nvBK0C9F=hPURnmH_;8Aw0!fX5NkK}j0j@ICNLtvv0Z&uu)dahfKTA&^(KvM zaCrgld7JU8a-%<}TIy1K^mYEAn(}MWX*)(F6;IFY%ggfz&JRl5fHmWhm-lG}YK}#E zE1Vw&9y0*!%TpqvylKdy)a;_Lea!5=EJQ(RAg|2SzPX zEA2#WnB}H}-~Rq9^isGfpZ^#tuje_l)Q&WpZt1;+XK|Rn>L)07qQ{#IPCh#e0UTxW zNM6SjDCus~oMtz&L<&Tqp%$o^N=njwUi?xAN6pz{pNRdslh&;wY!HiwpRI{LLbl+C z+FJ^m@UC4#>?@(UNID!;CYN(TQs{#$J(UG*cdCrjFQ7_0tm=E91B>34Y=y}@=9SD; z!2P|Lra#<*j{v?drjmiC(l3?+4*#w9?r*B&nkc7jv-=BUs_~d4uAPjfRSufS^q-o@ z7%tui=V2R-zVoZjiYi2z;T1h7{}RDgysw<|Wdtf2d<_DsmvpW1iQ?TWGGFKKi}`m4 zD)^U;l$hq0&&F2}+jwNW25TMJ_xSFLXpn0&-X)XISXPqH$iRy#U}>BmdG*&O7k+c= zf7p8^jQ5C116m1^Tq4X1&h1$PjD21p-Y*T#Er%DqCMl~WQ0>Q~#{i*cv5h%RJ z{}KGC$b9#wU}PXfFob6|-epy%wlVr^tDe=>D&t-LeVbl??W3Hf{*#>Lzko+kfYl^^ zReu4`6jQl&S@R&2ZIssDW^(L;M!@A8R<`{Z!mkdG;5E)Axq0v&6|;dFt8$H0NB;l@ zywy7I%!*Nl7iGA!NqbYaG3wQQw7+^3d^?HZ!*FNP#6F(GkR1!naQExzsYKDc+Zo7k zm#%j%IURKG0}XKa<;@%`K>3Gus>Ub9WVWugb zzw)1h6Kr-B(1~_B44K|VNls)fa_wMqh1iEd3s6@as`YvKh^=CGm)81w8H!YkV^T|c zA0ffE&^V)26!Y(0?#ZdEXpogv7+;0J8l%ziYQ)DmR$z;T-B&9hZYlQ|kzang-R%Uu zv&Mm=MT~QnVF_}A*))LLOfh@K;`<4@|D4CT?IbRk-Zzr~`T`%O2-^4h)kogq3By3= z4;Bo47h9j^;rY--YBDywayQz}kv!l=k>WhD;yP1yb$Do@5aU{%a)KdG{HYupPMAYjXuDQhE1nZ@+muYMLVrrtmY3pHUJu31o|&B z&^RE^`k7r~Q2H-w7XJev-j@La@LuIlfX-eV5?a1BX+FnSH%>#(BeN#EZo%8!#OAns zvlogh6mX-lt+D|dUg~4V@Fb&}B`_qcWFxA9+Z!Q-h;g?_aVWQz)c;aI6hm{Gh64pR zs;?s`lEpXM-X@M1M+}I6?z<@YyoACRSE&fkXK0`N0g}!0^-p@iQmjAeMMwp=p;;m3 zc`-))mkm%Cxmip8EB!Se+2~nmtJek_H14;%T~OyZah}d&u%cey7Qa|xQ5Dx48>BEs z_kih>WW^kYX59v+?kj|9RZV_dcnm#``H)d2hwzUxcbT)Onl{G~!A5S0m0-u~@gl@u zcKajMgXK2YO4OFiCn7nZ?~MjJPd=1zaZ8Sd?ba~Hp<@vk+}{Kp1d@MnAB9F(&gqkH zsO6%v5ODf9w6l}-wZ*s+E4DhIfM{*#(B61&;bq>*IuW@A?TdcKq>D-({a(8f3v;N@ z=^o11HpPs%5%$)i0k1wsS4ho-!jp2eV+dGXw z2iiA)k)7Stl+?oX$AE6kQno;3bh%4?mW!+Uz^+TTeHAn;4*Bd?BI_7?5nU#~PreSh zNs}X2eOForHZ- zvUk6|kQwWMcjx5iV`62>L3ifJL2hqG)_q1$>8Mkl?m zY-r5Q(xNaSwf@LqDLgpa%mSwVT~$?KmHke3T~6CvPlW;l{Rs%LH$E~}~*UuTStZJ-rIB~vywd{}6mfZLIP_-%Bx z{D#E{DnLV73egmXR3uh6{S4l*FDJC2non?a49+xFM^{!io!i68%tJb5DKJGRUCL*Y zo|l*U1It3!t4bg&4kKhBy?pCFOkXW!ChFptR{GY-tbMUcGWbyq84xRrVT4T~Erc^1 z@oBT{NSd1%M>~B z=A#%F5E|Zw{xlN9H4H;jU3MElwcmqgEyjS?qJ^*gFBJw} zh<~bp%Qi*0bxthB=u3K+B580*q>D?6_p+>64%+S1Pd(nn*i`r~s+&nZ2I_xsh* zw+y}!Vu3~WE;vS?2wxD_xQ%f-R&ZGf=zK(+pV0V2$+=6F*9vJ8Y*<7u=GHPLa%H9M z9@$2G)sZFN!&Is`?%n|6ZOvk}{^YLhf^Q zsX~oA3ym4J(hT|#(S(?Y6w+e%DPI%Z4DdKZexowH2}|rwuqlFCN0FBcN|4Bw6L)A; z^_A-A&SK%^3v%tD-2ok8Zk7Uyd2>9^e>?5 zKY)<`4b%t$%6~RH1xX@pz~OpcYA^><7<1OGtS))j`VExsn1a?l_xyiXEemA`PQ z1)hbzEfSN<*DA_u(k>G&Qhjlrxu*>K?W2edIqO$iH*wg~_JDQKx4ywG($_iF-)P6Mi|FaH&~u zm|$UaRXyO!3uAP3QHG88#=YDH{PTXkv3Kw+KQ|PVYb^p4(Le9!YumV4OIuo6n7RFV zRo~wrwNR$v~_HzGxs}TXVm89>?Cq{1Ysp;Qn|%ggWTtv%s^n^6jC&gKE{}Rn8T0Wy_AZ?4nY1rckL*Q* z&Z1YJ%G*=XjO1kmTg0fVyyM2Ju=Iuh#dO>? zX*4%9Gom$h*C(K@+jDu>)nzafbZP_gIpJOUk(i{iFT+62cU5O(6p`~YFJgIkVJDyl z?Q-RL`$Cp=dQ`C1(V1_9@KaONIIEWv5)J2f`DG>Yq=DUY2@71QwMTTNtYQ!1vv1`qqfIK%zP{HSsMSABc;ALUepg~C3 zk!)bMba?p+m`cV^vi#g2Oe#nwKrZy+{wTOsb=%olyUy%^Q8{1x^@+N=w+dTQbmB+n zyuXWO`{IdJ=lmOA4gu14)1t)$90B0EWh?Yom80`domrq&qpzC1tU2rJf>V>_;F{~5 zE7K>ipG*7m%{qC*Z94xoJ3IIpSpRf0c>HtV_KaiI>7HugeN>RP)_OPoz2j|%mkz0; z`Grld;*S>dJN3%xN?fj~MbVdw+E%v-e87X!GmBxP=O^BDGJ4d*&Gy@`Z*Df*2&f5| zL?Lf6?;89b>r|AxdPQ|q(4QYhjan)vd30CS*HubOL&#HDE6KIuLrN*l}F5^7`Te$=+7^WtDEhGV~WDe1ixzhd8?XvsGIiKhzS6r)V8BkwpLPeaVm3D zX=T|+?*-L|J#I21sMH!A+SGG$SHLd!e1WVKiLBPV%de~Eji2ppWir7d(KJ* zXFk`hJF4C2n~ggo+Do(6szk5aY6D_T`G88gYpd(6m7{vTg^5qqBLl3{(Gk0|dtm4G z?gw}9sA0vYTwYJ|IFd48ACk{*>HL1_lfgg)xi`~2HEh4dPU*a(5g>mjzm3TWt;Psu zl?dfUN2F%Wa7uW%|C3+pK%6>YRJ}e49eH{%=5wr*P*knyTT;Iq+SiQ4n_h`g4Op#f zkt+;%EG^hkZMI;@n6&SuMEJx_c zJ6)I`ku8Bf?}qfDBE$!n0|$vV@%v0-olrK;$I4rv(A6~{9rQmT9TX>!js!?YolG0N z)4w4|ejv6&J!KI0Qg8hZ`YZc)kk_OGG)YO>Zc&s|GNr8?pAoGw1S^7 ze2U*4p-B*ITA|QGLLp$>|Iqp`Nk3r@nXv$nFeFHr-M_;60FEddY|_oYaaLjYP-lMQ z{5{}rp6g_q;OF$;tUeD>d$|rR}0sZCzm_?ae z$3d{8N6ffd)6)#bDO+gQ#*JW?(OdaI{a_My-^%#u%E`^*GEo3(jNQ8N%l_kO#O`kP z*uX7L46(eZ*En#^gU{psRG{7OW#4Y*(C&u2dUTr?rPOmAgW&nj3ygT;;`{4qCA$Yt zD7$m*;TKEItCmjlk2y7Ds9Wu#?%PLbWHEE=OS^{3!ffX2lOW=j%BT2f^Qz}|mhMf^ zWxW5dulw`&qndV$qz21kyl~r?WcTMQ+`I-*>tL4`-;RV)!52H^?IuRa>t#@aBKCi zuQLFB*WNDRat7H{z;Z^M*=fA@=>_@B)5arHjp)bHS8(J%1&{I0&IlruwtV2{ zl_l#R(P$--gvB)~ylwqj>jNkOI^;#Gw;wA~Z3 zc!n9-Eq-5%xaB_+tBv^5p>QPyKZKa0LFH9|)t}TuztC|y0Se7y7~0#$titeO~Gf!rnOe6A(>T|}v0 zk~l-DSxS{P-9zEt0tl zywZD`-5zUrY!Wr(AyKSgd2Cxc<7FjbcEkYO%yFg1X9jXpRbf0_9$*Sy| zS(SOWa*Y#8oQ6c~QxhBbDC`Re#4~D+PG`7J)i-K#=N@J;TfpBaU@wfC6}T09rU=`( z6OE#!u58KQ7l(GNNYm?ko*h z4S~OM(FBworVK9KHP<+Cud4>8+LxHf1_9Q4HLbfr0G|J*Ux}^77SC{`HYGkbIJYO> z^D?Dfxgeq}%`|wuo64^jTDfP*=!M~k^CLi6WbLB<;ytY~w=1&2?P}k{k2U!4-K>|< zqREI0`o*oWV8|;j$Nbym7YjFqMS8`wg2S8jdj!=0`3c;MHA)OmiJT{YydMRepm|Za zZm+(D#0BiTh;BJ@i1j%8vI2wB`dEGZ&EvF()s#mb^xZY!kwW;jUFY?>g}4_!&IU5gC~oeKJIh91U9`uaZoh)cl3%AJ)|{w{7Vk5- z;0xp;w0GKGHLseH+ng3miKfn!cG+^JH?LX@Y(6|R8awagFfdj9w0Da=8jnlcO@qas zakW_4`l4^Ng;sm6RCY{Illi@jfZZ;1?Xi4QJMP2B!>puGL;+KCC8sF0tI2wju+GwV zl2>InClxg(li*3`W}%YW?_y!JtXn%SR_AiTbMevoT^$9S+x7eyrKz)d(#>@7Db!=B zqpR}d{RQXHyjQV|qj=Vwx5fIxfM=z7f^oS5a&lL&?!)A+emgm8hw=Oz1^VD#1wT+p z&fo@<@N0(~8@J{=J$k>+bJvN}XPU65~eFB(wJo~kTD-`az@fSht9 z61}$I_v8rRp0%B;AI7fk52-(%(tndjiVU^D#up`8(Fh=I-5m_<3p|IhboTpQ$J{IB z5BWGL+PwDgZf8#^4qlx<0G_^n@$S%n0!iYYdPN$}Tw~!s`CuYx3Mb+be>%fTC^A__ zbY;st6m;Bre|7pfF}D3HUL;0DM&cstc0sP%vKs5|6i!VwphH>nVwB4xsL1i_)^XEdfTlm%v&pZJQy9rR z9uY>s4^$cRh0}R8(B>)T%^V8U%UpC`AD5SYom2Cg#j{e!y~}Qkgv5%tCmtG1R%Prb zPQXiEG)RvHI41?;=Ad2XFpDJpZol%>>WRmw>YlGD8^SPsoiKFv>S~O=sF(y3Yu5OA zFQ*ty~rGttLw)4p_|+ZW=XL9sqsw{Ct&De6Ktr}{hUvg9(*-R^sKtYdWe+@F@MJP7Sced@*I6C#{! zXn9>x5qCKmjivN&8X@_v+kOrbodM>Qc8@nVqy}n#8r{UEsiz#?gtC%m)b%{vz**#L zd3~ZonGTf%$cVsb*%d2aAfEb6;~Kh|*+%!{O+#x1%Vcj@yFP1v*tuzJ+U8ZwEKn zbIx8D3)no$KuarC<1}utr@<8qYAb6|HVE!#DapPe=T=sOfZGacb)sAcs5tiK4 ztJrqWzAfPm{1&eBxOGvg@mE9amSAsCxYihge#7)Ssnm z-8|7lQfx-|){I}#ZemJ?1ow8L!M3t&IhN!*Id2>fFq4QR5d)=8<%hwgjYQP)Nd21a`6pv8m5?553Q4+8i4N=-GQqu0x=k=iKYoY3A_@r9m zbLZpEvdHzE4e~ZE@($rKxgs=QmjGkYgNm%^RLS<|JgrhSI;45qJQxDTyJ*O_t7k4I zQ3?VgEDlrhU&x+A+g6L))cbY#v8lUTD7x8Sir09QwK=odvb+{T+RRJZLb(mk@GLiV zK$)~5LMz+UGZgqPYm~@P!`f1Ns_#^H50>WQMGqQP5IraNYuL|g&~H=cYG52{pdIA+ z!`fouw&72K%Jg0LYqZX5j1aJRAiqDa0!60=6kOD(l}gNc*`ReS?AMqv+VHt_{CZ?1 zW9+8_?Rr4pd}j zSng$$va!>&gCkb?)x&0E3P)j>;f|yc+iJ(GjG%FRNXO=}tA%gpKEnY(aldB5LUS!a zpl2WMdO3=ro1M0YeHvKgRB&?#$A5u^NwueC^5{v+A%?`|OLxnaSYwZrv~ z%@vcBGVsb12u*PaF7y%coY1v@w53>u z7zjv&m1!7lX&(NNs&jpEt!9ScTAm3AYFXF?=a@;|zk8-1NYGwj+${`>Mkdl+1PqN_ z44GtHKQys0G#Sn8<-8LPih+PCGE?Ul5=p%tYJFLyo>h2%cB8vC_^5j&1BvMT0h;)V z{jdGoeE5HdL<0VI$jfyLKuEW%ze4gn zhb}i*{3~QO1QLm0ct3(%f3EUm2{DL^HMI>o)+Y5+43bckN;;#c;T+m1oV^hQsi@%{ zX%NDCSE?(GD3GBgL}}fOee2+NM>% z&Q!x8L1Ha%*;u4uy05c?LUuKUc?f~J${0)^p#S3mov-OUu=iS`dajZL~C=i%+0e6fu7Omipc{mVJ8{tu>2pH)O(f|}O2&xE}K2z45jJ0|k zjBlVQ4RgS12}%TXGh>ptY`$lQHrOD9XjAeXwqQ6AF$gak@C|m6_eiB))1(bDRK|RV ztra@f%Nj>86c0-Xw+*r;YY20YPWk{8vgX@(KIn5Tc@7LR5LRXYad|G!0^Ka7Xl18ucOtd{Mz-Z4beYMhX~-gX1y$p}kmv z1nggcjb(gcg(t4h)&8f!eE-i4CXY|@YwX_X>G>Wo@0-5$2FMDy^j*Gop52fH(1JlW zrobWL<-hnE#(&TrSogh=s`$RQr-C~T7{!M}OyVZNz)xl+MW9aVB>jFCoFo_oItc-$ z;rlTZFyx;%fK2#7i|~7FWGHQJb4F({>>+j2yGg;BhpoGrt+$=6r(1F|EJOyX{9*6b zAb^MPhd~A@E;`ae#1d|bDt$*<#|zQ>nC?mASTy>8>^hD1xrlH~ zNzO#g$#9T;1D173X3VA!O4)cd>H`M4$dd_OyDPsiJX}MkD3%_LO$8AL3^( z@~6J9G+Xa@bNw`1-#nJcPPYdOTy0!De8Rwz!|2l3*44~b@YrtW7-6Roo)|f-{xg-2 z?}HbzzTfwu*z~o$cS%J3K4$$-J_5+YL}OL?V+it=bJ~ogjr$ZhY1?1Y&&A!oT2Z&J z`%jz)Pg4pJqnH^OsTn7jI3{PCK1?_swspHo7S^|~+}*y@Gdt6A9`7~Y?@HMo)$$X8 zXbjLe)a4Cb{w-{6w|DIa;0mVE zx^0x$f67_|FkK>e9`63AmO-f7#j5keQ7{*Zpu1MHS5(gGP2|p?H<-vD4y`j)n7mAD zfvO|a_aoKsdrP+TNw6}?u<|Wn4(&y!!8w(&8oG2C{7;n5HNw$-vdm;UkBy?+*O^N( zkM}VaVPXBxk_)76-=yx3J5zqBFV7I{m0S769N)-YlnI|Iu##Vj6-IKmzP=}7>^}kU zhA-3dP+}Na=;>Hz7}-YW+k_JaecWU{-T|(qXZ2)c-M()Ae66YTv>R+S@>4R4>iI}q z>@m|95Cb;wc;A?^g!v^OG|+AubWh7kRUv0Ek*)d-0X>N2m`C+$0v$ND`TwHl0^t}| zf8d{b9Q?3lNL%4AFUhpeo*vBZiX?Z^o(}epV7Xt08sVuGG_1cW&${mk4F-`t5p~>o zi|}j7Pyr9Z0#$0&D1p`>ho2Jp{61Xuq4rM>>N?iQCY6-I%*NP4N5zy{l?2v^VerKV zQpGp+5|~Zg@hkhyn_c`#vwC@zu?(wQ+Gi!#%Jk8(I<-8I4~%8mtiSK_-omAxBHR2e z7YL0iUr-JowbJoiQN2A_H?;@O8T*K3-0WRqfqOaCEOfohA8vNB?QyN|4ZdC^un}$ zx-eUirTOXo8nC3B@ZbXPBGSw>avD+Q3MiZ@P9e6UVY20@hQUTbbsgLSoj0~9>mQ8Q zj=?DAeggEy5KD7LS>j9~wkDUCOQ2#N;{7vxt8nv^a2ceT3t$Kq_yssBCTX@qT7?_M zRLU?*fQZ7sF#aXtLQsV5j|c#(|F?*kVbLa;9{s-sFy})rz|nRSm~R%bTp-O{g;4*Y z<<8)T@2^TphSx(1zc7vG1jsSI^b3{}X99zZN0|4fTe?$!1@QS3AA*fI(3eF*I=Dv=W(D4sSk;OD|%i?A#FAz=?R~Yj;`RBl6EpKwwB<`YNZv6G zxZhDrvfG}JBP?d7xFf6^m%=wkm2efWfdgAYGEA~IZoxzY&r5$1PnytnIBt`w%XCY4^Ph4OesydRT z{TquZag=0CLCfHKai>9~jg8@ZiR*%H973s_d-XJjk4iGj)eX-J^?YYz#GAc&@dAQ@JA}T!QmjWBwd`L?5?c@GU(OvmpI#lvc z0XjZh{;lIqeenfyuZe%B%d(!&oVMncj&8QI>j3NOo8A6_ zW-z_+&c53q&mnoDmT1Y7i^JytY;7%X^zONbT`L`At7O_gjAUYmbMCJEiOVu)BN0QO z{*L7mDzPyhe<}YX%u1iGuIpqVg1)E7r$! z27=j%EEFq}xn!ezWD}lK zlNnCRa&!7cNf1Io=v`+u`-TrM{Yo@#TzyYboz`Cyv>~)uIgo3Oxfmgcu0uQnGMqgdiXPo&7-dpNh_;JiH5?v$7$=-v z+kYiaiBqRqi5fDnLZ9ah$p(uifE``6lYW&=;6GfIEXE3|n?M^NjNzqX2%Tz8B*BTb zoMS-50Ai4}X;#Bigc}0Y-Sw)GaasN-Y6hwzEV6IaEK(kaQR#7=CthXh?Q1A~)+igv z#0Uj8hKCH+MzP_d8Rkw#KOkBatR}9Ir@WhJ!*2OuI>9b|DO9~LYUw$PQ$V7xK0jF- z#cnJD0SF$0VfbXSJcD7QD$QhEnTEhKj1wwtD7P|cAH_a{2s(^|0M$aa>bT6|MrD&F z3@}YZSwk)R5=*&7EmgOOif$N3J~%a!-T0WYCO<_F(`Gmn&KQ0ehra(}tUQZ;wHys` zRFx6mHJm+KzpZq~`G1qeL9_Yj)%&8Z5g=VhME${Lua|Y6UAPaw=x3{T>T}EWHHZt6 zslqU}o4F!QU}l1hrjX*;!BH!i{8j!B|{CzH@e3ypE326WLIdd zqE68m;+Lz$4G%G&H}lXMr=moDgr7&5aEpG~Nj6)#ZSxiauc`-!?xLL1s|q)n zY^~HlDTWSB2cs;e6_p?PS|#OAV}2_lj*fnURbH5&${cNRm@)Tdz&1bfPya=uz9urE zA{OaIl^?k=C0TB~H+!0tI4xuUL_#Hko-#e5C`KNHqN+5Ji>5|aPbGCqJmQHJ+Qj`6tSNv7&=a^-YUadzQ9!3|YzU$;XDp)T!u*q|^oL@T&i^K(`)XK>{qs9}8x>B9mT- z9@nGx%Ds@2y)=|YvX{FeX&}sY)KnWNf7TXuiYRHjWvPiUyGePvGw1p#wEQlbd36e7n0A;{j@U)bf2Y_-? zp~U~7Oak&x%3SGl=C=AHnT8ySVli=K3h3PY76rc|%@%oBMPrRkxEUk}vokR$o8RoF z>NP%A$;)Z{4wbuAn>~e4CRg~dEFhh)CF!l5qm_=p7mf%m9N^0?j)Y>qaIS|O6mKf& z2-;)VlPRh9XB0*mVa(%5ZfzM9-?oQfBB61P+SGm=Q2UpCyPZb-ymJQDz?T_!Q-`0D z2Dw4K%1c4hT>t_m!X*@tQKq3iV|mLs$D5pXMY1MZPNrOrp&ZI*^G>c*C0*=Pbv6rl zHfyAYWn)a^*IM_t>^0>FMY7{bc%PNrpoq>%e{X;Y#)aa1<)eY(6c)T}_yNBUv6oPX zhA&!_$T@A7Vt+LuAWgI5nB?U75}Nm;3bZLi=_)38=pho`Y*bT{E8cPPAss;+w9Zt? z2_{;IWTi&%QaQs8{Ck>|DeZi>?vAz*XrYz$3!LEKxv07}DtWdCN{G_{%EX_irD*f!H;?{1r1{V!L4a2mu-r zMc#9995B!zr+(Z5F^M0irJ3(k#ZmgBG!zD)luQ+%R9=xEG~SUXfqq94+5$~MdPm#% z36l&@k}Iu%bBj4F7_=BbiQ8k5E{>R2bl?aSLz3%x`I0Ebv3;VwJ9udBCv@+njW3!v z>As_?rV|xywcubtu*$`u0z_|`6nKh`YBz`pQktWvc3Og?==ZdEN72l*M8{!xC~2;2 z2%>%0!pNuulvvaeW?rn}3&uZAF^D*{n*iub1_Up;+=>=4#jfYFBq3@N?XRmyM5zw$ zQyku-APDSgK$^oAt>Z$672RiuXuZIpAdqF>n^OOo$lZvVEbqr6bKZw)W=MgR$p4n1 z%LOtsbv%g7L6iv_HU~tOEw8G2dOOBu(aqY8Ll^Aa(_ z#N%rt2?;_t7tw)#xJVlbLILh5dBr-i3lOi!^njqXTKQ&9UQ;*^(Z#%88jK5uQy2z? zs^0;^>(A8OL9_%&8K%f}P^u7jsJT%gy!)>0aDJe*aQ<}WzU4#2Si!e_`q z`2Pxm=MsAZzJpw1j{F6P^0Bpk2YBc`?T2Z{cDMi_kig%8_##qg?;uTeg#n)p;~ggK z#PPc=qMFvw<6>NXStJe@@XsUuFa+z!gb-pfwg@4Y1d93vdVrYTgK)`RCK~CBHF4>zS1C<8m<^!svYN~NS3HhAv_D?Rj!_*T=A(Tl z#v2}B!gB<8_Fqy+1AYxb{q30GTS3(wBxUBvOR~pl2+yY)*x!y}3DtHRa^y7$FyP-k zl+(mOxnSHuPHTCe(ETB{?yvGk5)*zAK(_K(-6s=_1Tt^%=s&gi1pEq$`;U@^Um}1> zFR6U4xm$o>0g<->C};GzKSQ{_Z@vU-CCEm9T7%nW?vI>1NL)uiYaAe!ngHY|!n`9P zoHQjWcxc*l9w7F!A0V1fGXenH*L`2CS~i=45Y9KchOCachCJfAx8<{m6;h8ZHDX<{ zF}nOcva`&BhWtXbS`9uL=HN@sQ`*TBXL$@-GT_*+DM}L`y===n62SW{iCBV3DIoA{ z@S7r;=Lmk^HhbtU#%rhEnvFl!;fvW>HX*_Q1EXPmSBABQ-)DMuR>~I)mGtvc?kE>ihMmyi-3az~2}D)s23SYDlSdSn2s-g2_;$I z+p~9?V=DIHx|PMolf+$e4j9;t^W4@kM?FwjhYTom+D2}Gs%s{`eA!0865x{JDD&jU zg6C^px-tzzm1oU1=bHJp9`SAL4zJBr%ztT%vCQ+LqxbSI2w2|bYt9J(rtA+)+21gF z@?|f|Q}}bT?+a4@2l0Y?%5UvCsB(-HPD1%2>|B z{~7Fyd>QxU!k|^?l5NP+w2C6;zvyg4Y_H?TuK)1ytj@ubcL~cmQM!^FWA$a~x4Nla z+JeB?WBY1Fw9mH58Xqa|B31B!Tb7YO&izLUyx-Ea5;($tfN};b58`Ch!MJALoin%h zhqEBw3;`nb$~xfb0BDPXgI28;MCdsui5J372z_SDFy0aE_T>muzhT;9N6$IFej5&v3=%OFo*A0zLS$+IdVBjZkU{wM^Qf8H06*FXL(ib%%b4dp|X`Elq&1XJSMBTLQr^X=H#>7Ie< zv&P5nO&v`v;bPKQ%qh6BA4@fHFyxDh6wFhvbB)Rw8OgN_6*@>I?Aa0v^*t5 zOR$5w2z`LIah~?Ubk9qA&H|=agcSnaE`k=;;vl~t|5l;#BE(64#<);zj>4=qP`LdH zS^D%T@*L|%QPwn|lNR5K13MmuJ1#En*3EDhRx(4Fl-eK6rKTjQnWCcn6Os}TlKVW( z8AYLxiKfcJqYGM?JHQ#_SQO5|oQCqSf%WV94|rA^Vj=F1Yz{SUYB(#S9&SQ=C2lKF z7e)CaqP5BAMF>GxTFM2gbg(NhQMypGAZL`(rn~5OPpZp={Ot6C?Br!EIu&^;X?S67 zYF*20XJsnZcv5(Md(Si_d6<+>=bEmNRkLK&glBTV;iMq@QI;EfBq6q!p2uPNmItt)vRDwrMQMr?ur1u`a|7AJu7D`;P}M;!$ZLd@RVyf$0l zU_8e2%fmhfdmslBWQ-t!bB!&d9gt|7I8)vnq!DwL1ou#*5GmAwAy#7v-YV(-H4N)c zEmcL8R%y0k@>;O7a{D~jaj~K{sRYj3);&YY0;$0@%i3!>AYBwk1f+{BUQld^$HfMd zQfQ;T(>rSqE&2J`P_&d=QHfucF(#!nl2I+L=0>leu$!Rpl9Q&aR8o@u#fgK}tJ$BM ziqbjWhO$Tk7HA=NVF+<3Jcl)Aq~2Ch-n_^WtpS{2l~jnES%jOKx>g%NX<0MmZ%U$~ zct{%ZqORE3$D+mGpaBO@D#~|d`2(tF5)O@tz6iisqRG9lpL|u&swm%Cm<{$ZPFN59 zr3r=TpDZv^k^s&ei~h$MXB5Z+%zBrCMyyo$Nh!Q(3)WWWK`$~YN;_&wfg1PUI2}ih z5uKuma@jBu!RjOdQ`S-yDkkfqDP26SaCd^G3A`>FSzlPct@+sJe%0~_#XIhmD%9`+ zy6!bUSA1AaXcc1GbE5GSqfl-O+$kqh3z<`Tk^cp_MbeSeu1Zep=F9H;EAD52?gyl@ zQ9*VH+>#7+6;@y>1_K!VwTT)xlFHvNJ#isfcBDVIFcXs#T9ucW!o~06KNB*^%iLND z8;_C;71746_J!k@_%UvPxxxQ>t(c)5se*FH90cb!8UP#GQE|jCtwRSf?fDg(Pgv=3 z$I@|^l#c*AYqujEHOI9e3(AX$vuI%!MhMPQp-x!sV>=%`Qn=ubQ;%09D#fedA1%if zIu7>~tP9E)Y4O2rfFL~lMZj~f=_r{5n(to(D2^ex(Xl6-*mAr<#1v%7%0ev06PB_T zMTp9X%B01G#xeCzV6CN2q+S?(x@WytJ{kB@4k$h_H_rGz87c7E?pn_gG|VofdJ zeIsPV2lTbJk+R{-bjo}gc~5$l+Kc)(xirygS_(N-euDiNa8v9yjtb}UojlmZpIjEu zV!q1TqDq^f74xFA#q#GR$x=D}xUiUkoX;Vg(vI79T;3`rB?Mn5q-KMHVE^;B=NSpwQdT0aQ@L+m+3OxmKeK1WQv$PgvE zgAk8yFYL9Mu#F^*wfg7~pJ=PWc`Mj$XM?h>FhP4x~6=%R`x`M@x=+L7oQ8R?XH zCW5Z%SZ;^v$+GbNL#UWH+e78Ki*#BS=$mQQvm{&V|;yTV~k4Zw}q+Bzs7GPFBAoM_~)td;qBN z8<5>2XChpI!Y=7%-MF+U7r-~x~+H7yOt!@^pb z?VAxBQ)+l56SW~fk3aUe&X4Oues)88>kMEoB#Pr>pY|5@lB5Xgw*H8Yco?uin+ooR zUlp9F@{cYxqa{a?n%auu*G4GdLM&HX<1h8AB-gsp-1IGM&UQIcG;226!aon`AGw`$ zl1k`o5C%DrDRHn)h(b1$&AK^140?#$pXA+tpf?;&UtSOGMLYjg)~LfcEk1=yP9?_* z!W_;}h>zrVd3osASb+9!WTLP$I4RT&E^7%O1RuYfrY{akLLD5kbwZpxn7E)z{@x$2W(Veqs;nS zG;&M&czd=mKV*u2p}0BUMNIm!HGa@~qFbN*Gu1tSZe@LVbub>E&w5>HU0`u>6-QeF zK4rUtBmVtP5TUVELUJb-ng=7;q`{%QFA=>R#9D5yFRnlVW%F5{C_<&i3S6OfqT>tG zc>QR$ZxF81_S&fO<2qbFqv@?a2$Am5oMcp4P#|3RWyVWhAUkF%bEUefO~ntdMq~~gJiosj!z$_xk&gqT4_R1n##LxA< z#Z>mD;I(SZ56Nw6OYWzfU5UZOHU!7D`3?&Bl>06zp&9KN0TAnY$*pn23_3q0Xp!Z} z1gyR_(oFV)d=|U+@0i-5>_)H40+Q5U@3*7v84-TR2Rndy3cm+X#@Ey1Li#ABpeWgW z8Pk$yKx&CGs_E((RuonyPB0>b0xtCnrcLOEYolLzLEi}nY}@~}&vI=S_@B!w&$N$K zfB>bYS^8h|h^DcKmQ=81|$J>>61ZSs>I8@~@Un34erbn~YLH@j}I5 zWM07}UvC!cSAxGH2#3cElNeX9G)kQ!>5gzuU`OZU;je4rE#M*>Ur^cy3*>Ce7dnxY z>sz7MzSU9@EXLfmQE|me53)`+0pqTE-Uvw`)6YyU#Iv6*l!*s=I~me)3p>AGI3>(MUW>za0& z3^))9+f_sol$iT-?#1}?@f}~g?JHd)g(wY8`;UGcN4h~!=3<{CUc%R&sZbN@m=`38 zK-Ap~_RwOcX?tgU!)#djn1N+1X4E zZu8O)-%$t>=@QjwX3R*zA_d2z&HN*iuFH)9EE%y!FAf?@Vsp048nSFlB_xx|pgZX- zL#ebXy0ggG=*)89_3D9Hn)$YUckt`!3I~nA<+297fDrEUARYVBQaF`7mcr^bQ|lg7 zxQF-d_!s>E9uHu?2NCllwOf#~y{GS2P;Op5@O8Bnx}1FNLzktI8eY(>c_yB2%(&@d z>Q37C6I}G31rLrO<>M1N>fLU~{kmq^_PHbH=lh#WLx`7m4`pMF2A@ip9M4OMRvw%* zn4OHMi+Llah(qx^2}W)vgYbg$Kx<|((4SyPU)q?vxC$w()PyceV!{{4b0{tA=`3^X zrREVV$NONu8-KVkf3~#u8BF)(@!QM7MZC@3Q=mviR8QhEdGf|@TivG$wCeAv8af^g zq(1Pcs*F|FA2@)6FwQjmIQ|wf>-Lt`OL1A|C08q6`R(q1)GAFDB55B5lu7QQ0Rlq* z_bKUWX=Z1}_}7v7uS#axat@muSiL_rkUEc+UpL(%JH@WnuUAXqQQAb}nniVNsg#J$ z$$H_ho%;Qaw(Eyru3CrcYXo7h7T+`N@mmqj46vv-BB_*|Pe$avlS7V?yzu$-87SYb zT!s+<{|Bqrw=3U69qk*>OeLlK)X+fkwFN8Ok5=`vF_NS^F;cMaa$1feRX>Q zY7+_$t;Fpb1jyxsAc3%V&zcUe1$wh(C?`phHWN{U%RA*o<=1psw+z;LSh3y2JH8i_ zVAvoCm@_fucXjT!CTV5~DbfTqQMy9e`KKX)90nmIY0Z`Mbq}luwb!SosVpn2%1k!! zTm8DVYqiBL%KiD)5;58rG{6?vi&9eYAR}zxiLO&I&fAhnDW$0jO*`kVIF}Te+M%Vn z3QIJcm$+)Ea$iK2sOse`iZ<;sEpf0j&H3d%@4cGri>)Xubpzjfd{EF^&IOlWY>nv)!FYV#^sZOba0il~>a)o429S)*+2?@{ya#Yhj@YSbWHY+F=HU z6PL4R9!`@r{Wswt3+=w)32GU~uJ(R=IowQ}8>o_C)c^8&ymB~JNARu-)(8FWgz|Cn zoAJZ;s7y!ghhPEya{8S@OEa; za_W}L#t6x=)X?^0FC612<;8;Pb)dFnEU8Vow>yxIbu#Gn0LMPXYI~YsuOz_}9EPpp zOA@&hJCY)p*n~JRMHg4Z0+Kh-X!cKAqbwzP2@t;id{CzbVn`3F>9ef$q}W`HNd^(= zrnA*=^999@Lvqb^BBq_f%I(l3nV9zd5&rrO>hq*h<91YA!eZ`a)2n!6A#kP4BxsL# z!=A*y7_zKswi8>NgiP%cD#}FC`@KWrH{%z{Op|Wh^v=LXyo`3`0{IWTSyYJDOA9sm zD^KgaQe#6t4#GR*5;Plyw}(vD@S?5F2Vq>$m3l&DC!h+4-7AUBy|OO8t_!I5J(zaM zY}%&0%eN-1?ef3(^K6GU8yPj0{oWK7?5uL5cDW5(SmxLt-W6vFnDhSbslfZg%A&lC zzFNBKhpE?E&(Z;Uc1Z-JLx8Z!`wwkDX`lXsM|PnCy=~C3pzY zbe1DlS0+zF3JM>-sT`yxcX!Y%FRxOTh+p4ZRVYPZDM-diAEtX9{kk?dM~*2Tl+AfP z%1}s^q?T*Kv7&53K{+aCk^_I?%bs(N)^N@yK<=Cu8q-Rv_bqX6Eh=ZKNSpuJTv*dh z^?fkVp?o`Xl@r7DASkG{Wv85j&TuH-F6Ibd!uJWJgHWReIk924)plTU!TJh(>}P`g zZ#S31GSC`Z?NpoR>{`@Ci0CM}@$GOf-IP{_JNVK{j*Ch1sune?742p1l%Hx*XA?;K zmTbbUOyZu#G%apyCNy2HBuJZXIiO5rsI8zSZWl^CJn2)QT5ccxrU4amENYCmCm^Z5 zKOD+k_Jt)uFlS-pY5R6WT{oh`j}^QJ^6wWMC&jB8^{(;1Gn6%EZ{9rGaaQ4vOR!U# zKe~Ly=w3=!O;!hLOs56~5`MoG5cyp(%AH1eFlQ@sq$Yc{ z$84y{VYJT@+&VJ7^J%{(z2cD%1-ovWBpWjQU092XDcvIc85z6Wct_Q~(Vy2rextK$ z-vxw&y?SkRzx}hX2W55lxI$5-z&9~$&uc-A#tBJBfZk5wU^-8+*h{ue4HsPOrjOtR zlu9?B|Hu=DHH(P0sF$Y>@hoUdKld}}pq=Bm-s|*!LH;ur%k}43D+B!CK>)H> zRKUB5n~ST1ou-{Fqm`?f-CsX@HPH<)gG^{ZKYhg}5+_P|!qJwLktmT1U}~jaze6N; zCl(uj?u7U4d3<#oJF%1d03l@Kk#7@|a&d(5z804!{P}Pq_<$6086pIkU5jL?Vj-dB z*6smLtN@BhGckd7sLPD845l6oV&PZ;e^wI?{XXu9)f|XgIT9FUtC8wW(ZYr!!FXat z-nZeWj*q2?{K`-Z!DVz^L!Y}%9#Yp1lV$20yt45>bn#Cfuk--)Py%!j{6p8@Rbc<4 z?ynNCF>?)o{2U2luSbEn*|O5if~r9C83klR*pav@ey%mV1hrE}a;LXY1r(cQ2mkY@ zi=7{0Rl5*H+(LP2Ws3iqrK&@0qYJ^%ex3sArkBk zrZR_`33?5#g{(fE=m6cetnr}PH4$bl2=b;&uT4|O>;*@`Z8f%mm{kbgF4OP8$NK0vTBKseSv zg#VGO=i?o#5dRnF`vo~8~*(Btzhg6bZ|6`OEKOYuh1DKPE0AzRn8FSdX*%_NT z17>CZWOp-~+x7=sSUtOCf(hcfEfGmVsr z-atW_op1LOwEEoth@1P3ytxRDLQ_{prOET1@^1aRWJa_tZwzPn;A856=52D_k==B~ zj0PzT*8w&@_;r*gpw&J#lx>I*uIAwUnUL-6` zNhi8Z2vXhVk+>6u;sH2?CIOp<;=XM)dX#w z1=uWjtYsvOlN7YW8XYb7w_ndQ#oXv$x8*klz;`JmIJRQ1BYMXeD5r&m{EjDL6tSTL?$PRo$b}1q413}E)$QLmySP; zyC@n?V@6^L0&wAaq$G?f=k5h7Q~LH)(`tLAJk}lup>2BR_iuV`W%Wg4dTxbxAqVs{ z^hS?Dyu}zH>~qk0zW1Q^4n!caxr4ysT^>u%G=-s)t-0utU=+Utz<$TzC9olx()#sa7 z7}jEAEcf%VUe!W8-;6yYpRD!YOxFv+(=iQoeXnYr7a)F*w0s_G)|My*K+|+hva1`_#U0tk7U+ z{vO2}nh6fn%tPq0xp&IGrPh(FO8N?&!xiYPtz&Q>$Ogm`ZnCgx*;u>NwnJ(cmM%Jw zZ9J4MO$P>(8xP$-$a=ri=^Tm)4N-w`>$8q@2G9!vQGk1nlXl=Hv;o}$f7MI|cD0z- zpQj5;xcniSOKcoCK9jsw)t!FNlftOR#x76moEbH6?cy2VMx3XfW?H9kV2v<2PK6#A zc=k2Rk`d2%{1OpL>U?MTTT~GEJkxBJ_Cx1@wrN>iz zCOHvrjYPJvlXaOV|1Q14brgtH9W!zy;iWs$+{PL|7c>$L3kD^G&%ccnJY@(%nH&ht z3`gSCy%z?g4fY6QdB^%y5cE658aG)| z8d|(CQ^Ly;KT8~1QA_+ZcKW5Hc3!QUJJBpnxS?vL4qWQfMnd+m*YyER-|zmU)7nh@ zP6i9&Tm@%H!9j%Kl%=H0-L>bBanWdkMYqrW$8UCuZcQKe7QcLZA@h3PCV~S62J(ih zFe91T{1cT(cYMhasi}jGa60c&2SVofmqQX=HEPb_Yv<%WEGdIqQqpJ&zG)9y^ILyx zY?|w~TkCF>zLO47P+2+Y^_+EYtagRJe4}<$y%BgHv5{@Y+JzsoF2=u~>|2@kIVV`K zZWYU9+ZXc;&pzQ|@#*q5>TFW!@NG(GyYPNNl}mhy)_rre&kl#w^jT*7=AIYHF1+oj zV8eCQhBY%eqxt4_D#x+BnDw-;!*`?c{KS0>6ZZV9e6_xWd!Fy|MHjm(;$E6F#oQC< z{dZTgH|}23M}MX_d@jEowr%~fmw{XSQm}kbgTc)Mmv2^_Pj=(~Af(35UvMh2VQZa& zVRdTG+f6s}MQ`$%txK5q;=!Xip-V0bCoKIhmzRA(VY_s5=R3A!u5Qhu9>ECZ!;&*! z$VqhX{Ip8pUt(%R-v_JOQxmS3h`f>D(m7nH^RQxGLgpFf7wkz-+IvsyUgC{^a;ZOl z>cs77ho4R1oc8vv@|^Z_!1KL@Z?`W=OY4=HYMc3K%ADQH&h4qb_NM7j^_rvGCS5YB zt}OL^`$+Xi)m^!R(eFR6pYp^bxcKy|YwdD%PcCkKEmvLhc-iuKbpRen;yZ8Czz1rXQf8U%w9RF3uvTDuuiu*6~&(-|C`~7cv zy+!@QN9*^;{`%W*|9?@b;Q`($b{VlZSlU!ul&hk|51R!yPWQYOzrIlbj) z0r0@(k`4UJn%JZ=7Bpfd{u_6EJ7gc=&B!DI9vW^K^YiBo|s)KkDQH&C|& z1{#233=GlEz`@|uyps5k%7WD5SWxQ>-GE9pZ-*a1rT2j2$lNg9K)QkP7|;kv8z0=X zN7vZV#vPmqRB{ohQ4mGrbD#*k?n%xs0*^$aYyWclw)ktH_8b-l1_2c9^*|B0_QZk$ zY_6UXRc&GdRHqGWf}&bJ0~iqCnFF8^rMW=AW7D43AT4ABwEQ&C2pJU1K||?a?GT?M z8-g_y*srXgS*#Ci3Wu^X@S~WN&I2{6xTG>C6+B&u9tP+KO2YIvFtXdAXhu6;65Sm1 zc~XQq6@@T!kS0;kwWCkGAhf5ILban!!=RghJ`sU1VMZC$1e9qBbp7a~#t8k|l~DcY zW60=6p!dHKMyNDk?Tw=wfZowX81M(h0Bqe}bhFU=jtH~9HljNQ(UU|s1-+|L?{3N 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 index 332f518184220350bd25b34ed66991d4da84fd07..4fac204df80cf01be4dfd707153d82a9c46b42d3 100644 GIT binary patch literal 53461 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 literal 36951 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 K?D3!*!~*~dgz-uM 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 index 59d05cdfa325411f43dbe3ce55cfd4712177dbd6..2b29f3f3aaf73e5543d83fe624e49fa8e104375a 100644 GIT binary patch literal 41242 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 literal 36881 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 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 0000000000000000000000000000000000000000..565a9b0930a2469cde4f8af2b758e632cc8e59ce GIT binary patch literal 58034 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%0Dwvu@u~*w5_& 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 literal 37224 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 Date: Wed, 10 Jun 2026 23:56:51 +0800 Subject: [PATCH 111/111] =?UTF-8?q?test(regulatory-info-package):=20?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=A8=A1=E6=9D=BF=E7=94=9F=E6=88=90=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/conftest.py | 8 + ...t_regulatory_info_package_field_extract.py | 54 +++++- ...egulatory_info_package_package_generate.py | 156 +++++++++++++++--- ...regulatory_info_package_template_config.py | 6 +- 4 files changed, 200 insertions(+), 24 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9912414 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + + +@pytest.fixture(autouse=True) +def mock_regulatory_info_package_page_count(monkeypatch): + from review_agent.regulatory_info_package.services import package_generate + + monkeypatch.setattr(package_generate, "count_document_pages", lambda _path: 1) diff --git a/tests/test_regulatory_info_package_field_extract.py b/tests/test_regulatory_info_package_field_extract.py index 0d50569..b84754d 100644 --- a/tests/test_regulatory_info_package_field_extract.py +++ b/tests/test_regulatory_info_package_field_extract.py @@ -1,3 +1,5 @@ +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 @@ -18,6 +20,57 @@ def test_extract_fields_by_rules_finds_product_name_and_storage(): 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", @@ -33,4 +86,3 @@ def test_run_parallel_extract_keeps_rule_result_when_llm_fails(): assert result["regex_results"]["product_name"]["value"] == "测试产品" assert result["llm_results"] == {} assert result["llm_error"] - diff --git a/tests/test_regulatory_info_package_package_generate.py b/tests/test_regulatory_info_package_package_generate.py index 6c47560..c1331a9 100644 --- a/tests/test_regulatory_info_package_package_generate.py +++ b/tests/test_regulatory_info_package_package_generate.py @@ -1,10 +1,13 @@ +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 @@ -18,7 +21,7 @@ def test_template_config_uses_clean_internal_templates(): assert source_dir == settings.BASE_DIR / "review_agent" / "regulatory_info_package" / "templates" / "clean" assert source_dir.exists() - assert len(config["templates"]) == 7 + assert len(config["templates"]) == 6 assert all((source_dir / item["source_file"]).exists() for item in config["templates"]) @@ -26,13 +29,12 @@ 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}}", "{{applicant_name}}"}, + "ch1_2_directory": {"{{product_name}}"}, "ch1_4_application_form": {"{{product_name}}", "{{applicant_name}}"}, - "ch1_5_product_list": {"{{product_name}}", "{{package_specification}}"}, - "ch1_9_pre_submission": {"{{product_name}}", "{{applicant_name}}"}, - "ch1_11_1_standards": {"{{standard_no}}", "{{product_name}}"}, - "ch1_11_5_authenticity": {"{{product_name}}", "{{applicant_name}}"}, - "ch1_11_6_conformity": {"{{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"]: @@ -42,7 +44,29 @@ def test_clean_templates_expose_stable_fill_placeholders(): assert placeholder in text -def test_generate_package_documents_creates_seven_results(django_user_model, tmp_path): +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( @@ -55,14 +79,55 @@ def test_generate_package_documents_creates_seven_results(django_user_model, tmp results = generate_package_documents(batch, load_template_config(), merged) - assert len(results) == 7 + 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_generated_docx_has_visible_prefill_block_near_top(django_user_model, tmp_path): +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( @@ -74,12 +139,15 @@ def test_generated_docx_has_visible_prefill_block_near_top(django_user_model, tm merged, _summary = merge_fields({"product_name": {"value": "测试产品", "label": "产品名称"}}, {}) results = generate_package_documents(batch, load_template_config(), merged) - docx_result = next(result for result in results if result.template_code == "ch1_2_directory") - document = Document(docx_result.path) - first_text = "\n".join(paragraph.text for paragraph in document.paragraphs[:8]) + for result in results: + document = Document(result.path) + text = _document_text(document) - assert "预生成版" in first_text - assert "测试产品" in first_text + 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): @@ -141,13 +209,18 @@ def test_generated_docs_fill_clean_template_body(django_user_model, tmp_path): result = next(item for item in results if item.template_code == code) text = _document_text(Document(result.path)) assert "甲型流感病毒核酸检测试剂盒" in text - assert "星河医疗科技有限公司" in text + if code == "ch1_4_application_form": + assert "星河医疗科技有限公司" in text assert "{{" not in text assert "}}" not in text - standards = next(item for item in results if item.template_code == "ch1_11_1_standards") - standards_text = _document_text(Document(standards.path)) - assert "GB/T 29791.1-2013" in standards_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)) @@ -155,6 +228,51 @@ def test_generated_docs_fill_clean_template_body(django_user_model, tmp_path): 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: diff --git a/tests/test_regulatory_info_package_template_config.py b/tests/test_regulatory_info_package_template_config.py index 506f9ab..ed4e132 100644 --- a/tests/test_regulatory_info_package_template_config.py +++ b/tests/test_regulatory_info_package_template_config.py @@ -10,17 +10,16 @@ from review_agent.regulatory_info_package.services.template_config import ( ) -def test_template_config_loads_seven_templates(): +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"]) == 7 + 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_9_pre_submission", "ch1_11_1_standards", "ch1_11_5_authenticity", "ch1_11_6_conformity", @@ -45,4 +44,3 @@ def test_template_config_sources_exist(): assert source_dir.exists() for template in config["templates"]: assert (source_dir / template["source_file"]).exists() -