🦞 飞行侠实现:主持龙虾生成纪要

核心功能:
- Meeting 模型:添加 host_agent_id, host_instance_id
- 会议纪要 API:记录获取 + 纪要上传 + 结束通知
- 会议结束自动通知主持龙虾生成纪要
- 平台留存纪要供参会者下载

API 端点:
- GET  /api/v1/meetings/{id}/records/ - 获取会议记录(主持专用)
- POST /api/v1/meetings/{id}/minutes/upload/ - 上传纪要(主持专用)
- POST /api/v1/meetings/{id}/end-notify/ - 会议结束通知

测试:
- test_host_minutes.py: 完整流程测试通过

算力分配:
- 中央平台:消息路由 + 数据存储(轻量级)
- 主持龙虾:生成纪要(消耗用户算力)
- 平台留存:纪要供所有参会者下载
This commit is contained in:
2026-04-04 12:42:58 +08:00
parent 7009ce61e7
commit 6d426db0a4
7 changed files with 447 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ from rest_framework.routers import DefaultRouter
from meetings.views import MeetingViewSet, ParticipantViewSet from meetings.views import MeetingViewSet, ParticipantViewSet
from users.views import LoginView, RegisterView from users.views import LoginView, RegisterView
from instances.views import InstanceRegisterView, MeetingJoinView, InstanceListView, WebhookNotifyView from instances.views import InstanceRegisterView, MeetingJoinView, InstanceListView, WebhookNotifyView
from meetings.minutes_api import MeetingRecordsView, MinutesUploadView, MeetingEndNotifyView
router = DefaultRouter() router = DefaultRouter()
router.register(r'meetings', MeetingViewSet, basename='meeting') router.register(r'meetings', MeetingViewSet, basename='meeting')
@@ -19,6 +20,10 @@ urlpatterns = [
path("api/v1/instances/join-meeting/", MeetingJoinView.as_view()), path("api/v1/instances/join-meeting/", MeetingJoinView.as_view()),
path("api/v1/instances/", InstanceListView.as_view()), path("api/v1/instances/", InstanceListView.as_view()),
path("api/v1/instances/webhook-test/", WebhookNotifyView.as_view()), path("api/v1/instances/webhook-test/", WebhookNotifyView.as_view()),
# 会议纪要 API主持龙虾专用
path("api/v1/meetings/<uuid:pk>/records/", MeetingRecordsView.as_view()),
path("api/v1/meetings/<uuid:pk>/minutes/upload/", MinutesUploadView.as_view()),
path("api/v1/meetings/<uuid:pk>/end-notify/", MeetingEndNotifyView.as_view()),
re_path(r'^api/v1/meetings/(?P<pk>[^/.]+)/generate-minutes/$', MeetingViewSet.as_view({'get': 'minutes'}), name='meeting-minutes'), re_path(r'^api/v1/meetings/(?P<pk>[^/.]+)/generate-minutes/$', MeetingViewSet.as_view({'get': 'minutes'}), name='meeting-minutes'),
path("api/v1/", include(router.urls)), path("api/v1/", include(router.urls)),
] ]

View File

@@ -0,0 +1,39 @@
# Generated by Django 6.0.3 on 2026-04-04 04:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("meetings", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="meeting",
name="host_agent_id",
field=models.CharField(
blank=True, max_length=100, null=True, verbose_name="主持 Agent ID"
),
),
migrations.AddField(
model_name="meeting",
name="host_instance_id",
field=models.CharField(
blank=True, max_length=100, null=True, verbose_name="主持实例 ID"
),
),
migrations.AddField(
model_name="meeting",
name="minutes_generated",
field=models.BooleanField(default=False, verbose_name="纪要已生成"),
),
migrations.AddField(
model_name="meeting",
name="minutes_uploaded_at",
field=models.DateTimeField(
blank=True, null=True, verbose_name="纪要上传时间"
),
),
]

View File

@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""
会议纪要生成 API
供主持龙虾调用
"""
from rest_framework import serializers, status, views
from rest_framework.response import Response
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from .models import Meeting, Message, Participant
from .serializers import MeetingSerializer
from django.utils import timezone
import logging
logger = logging.getLogger(__name__)
class MeetingRecordsSerializer(serializers.Serializer):
"""获取会议记录(主持龙虾专用)"""
meeting_id = serializers.UUIDField()
agent_id = serializers.CharField()
def validate(self, data):
# 验证是否是主持龙虾
try:
meeting = Meeting.objects.get(id=data['meeting_id'])
if meeting.host_agent_id != data['agent_id']:
raise serializers.ValidationError('只有主持龙虾可以获取完整记录')
except Meeting.DoesNotExist:
raise serializers.ValidationError('会议不存在')
return data
class MeetingRecordsView(views.APIView):
"""
获取会议记录(主持龙虾专用)
GET /api/v1/meetings/{id}/records/?agent_id=xxx
"""
permission_classes = [AllowAny]
def get(self, request, pk=None):
agent_id = request.query_params.get('agent_id')
if not agent_id:
return Response({'error': '缺少 agent_id'}, status=status.HTTP_400_BAD_REQUEST)
try:
meeting = Meeting.objects.get(id=pk)
# 验证是否是主持龙虾
if meeting.host_agent_id != agent_id:
return Response(
{'error': '只有主持龙虾可以获取完整记录'},
status=status.HTTP_403_FORBIDDEN
)
# 获取所有消息
messages = Message.objects.filter(
meeting=meeting
).select_related('sender').order_by('created_at')
# 获取参会者列表
participants = Participant.objects.filter(
meeting=meeting
).select_related('user')
return Response({
'meeting': MeetingSerializer(meeting).data,
'messages': [{
'id': m.id,
'sender_name': m.sender.nickname,
'sender_emoji': m.sender.agent_emoji,
'content': m.content,
'created_at': m.created_at.isoformat(),
'requires_response': m.requires_response
} for m in messages],
'participants': [{
'agent_id': p.agent_id,
'agent_name': p.agent_name,
'agent_emoji': p.agent_emoji,
'is_host': p.is_host
} for p in participants]
})
except Meeting.DoesNotExist:
return Response({'error': '会议不存在'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
logger.error(f"获取会议记录失败:{e}")
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class MinutesUploadSerializer(serializers.Serializer):
"""上传会议纪要"""
agent_id = serializers.CharField()
content = serializers.CharField()
format = serializers.CharField(default='markdown')
class MinutesUploadView(views.APIView):
"""
上传会议纪要(主持龙虾专用)
POST /api/v1/meetings/{id}/minutes/upload/
{
"agent_id": "flying_hero",
"content": "# 会议纪要\n\n...",
"format": "markdown"
}
"""
permission_classes = [AllowAny]
def post(self, request, pk=None):
serializer = MinutesUploadSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
try:
meeting = Meeting.objects.get(id=pk)
agent_id = serializer.validated_data['agent_id']
# 验证是否是主持龙虾
if meeting.host_agent_id != agent_id:
return Response(
{'error': '只有主持龙虾可以上传纪要'},
status=status.HTTP_403_FORBIDDEN
)
# 存储纪要(简化版:存为文本,生产环境应该存文件)
from meetings.models import MeetingMinutes
minutes, created = MeetingMinutes.objects.update_or_create(
meeting=meeting,
defaults={
'content': serializer.validated_data['content'],
'generated_at': timezone.now()
}
)
# 更新会议状态
meeting.minutes_generated = True
meeting.minutes_uploaded_at = timezone.now()
meeting.save()
logger.info(f"✅ 会议纪要已上传:{meeting.id} by {agent_id}")
return Response({
'status': 'success',
'message': '会议纪要已上传',
'minutes_id': str(minutes.id)
})
except Meeting.DoesNotExist:
return Response({'error': '会议不存在'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
logger.error(f"上传会议纪要失败:{e}")
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class MeetingEndNotifyView(views.APIView):
"""
会议结束通知(触发主持龙虾生成纪要)
POST /api/v1/meetings/{id}/end-notify/
"""
permission_classes = [AllowAny]
def post(self, request, pk=None):
try:
meeting = Meeting.objects.get(id=pk)
if meeting.status != 'ended':
return Response(
{'error': '会议尚未结束'},
status=status.HTTP_400_BAD_REQUEST
)
# 如果已有纪要,跳过
if meeting.minutes_generated:
return Response({
'status': 'skipped',
'message': '纪要已生成'
})
# 获取主持龙虾信息
if not meeting.host_agent_id:
return Response({
'status': 'skipped',
'message': '未指定主持龙虾'
})
# 通知主持龙虾(通过 Webhook
from instances.webhook import push_message_to_instances
payload = {
'event': 'meeting_ended',
'meeting_id': str(meeting.id),
'topic': meeting.topic,
'host_agent_id': meeting.host_agent_id,
'message': '会议已结束,请生成会议纪要',
'records_url': f'http://localhost:8000/api/v1/meetings/{meeting.id}/records/',
'upload_url': f'http://localhost:8000/api/v1/meetings/{meeting.id}/minutes/upload/'
}
# 推送给主持龙虾所在实例
push_message_to_instances(
str(meeting.id),
payload,
target_agent_ids=[meeting.host_agent_id]
)
logger.info(f"📬 会议结束通知已发送:{meeting.id} -> {meeting.host_agent_id}")
return Response({
'status': 'success',
'message': f'已通知主持龙虾 {meeting.host_agent_id}'
})
except Meeting.DoesNotExist:
return Response({'error': '会议不存在'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
logger.error(f"发送结束通知失败:{e}")
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -22,6 +22,12 @@ class Meeting(models.Model):
started_at = models.DateTimeField(null=True, blank=True) started_at = models.DateTimeField(null=True, blank=True)
ended_at = models.DateTimeField(null=True, blank=True) ended_at = models.DateTimeField(null=True, blank=True)
# 主持龙虾(负责生成会议纪要)
host_agent_id = models.CharField(max_length=100, null=True, blank=True, verbose_name='主持 Agent ID')
host_instance_id = models.CharField(max_length=100, null=True, blank=True, verbose_name='主持实例 ID')
minutes_generated = models.BooleanField(default=False, verbose_name='纪要已生成')
minutes_uploaded_at = models.DateTimeField(null=True, blank=True, verbose_name='纪要上传时间')
class Meta: class Meta:
db_table = 'meetings' db_table = 'meetings'
verbose_name = '会议室' verbose_name = '会议室'

View File

@@ -21,7 +21,8 @@ class MeetingSerializer(serializers.ModelSerializer):
model = Meeting model = Meeting
fields = [ fields = [
'id', 'topic', 'host', 'host_name', 'status', 'invite_code', 'id', 'topic', 'host', 'host_name', 'status', 'invite_code',
'created_at', 'started_at', 'ended_at', 'participant_count' 'created_at', 'started_at', 'ended_at', 'participant_count',
'host_agent_id', 'host_instance_id', 'minutes_generated', 'minutes_uploaded_at'
] ]
read_only_fields = ['host', 'invite_code', 'status'] read_only_fields = ['host', 'invite_code', 'status']

View File

@@ -36,6 +36,14 @@ class MeetingViewSet(viewsets.ModelViewSet):
meeting = serializer.save(host=host) meeting = serializer.save(host=host)
# 指定主持龙虾(可选)
host_agent_id = request.data.get('host_agent_id')
host_instance_id = request.data.get('host_instance_id')
if host_agent_id:
meeting.host_agent_id = host_agent_id
meeting.host_instance_id = host_instance_id
meeting.save()
# 创建主持人参会记录 # 创建主持人参会记录
Participant.objects.create( Participant.objects.create(
meeting=meeting, meeting=meeting,
@@ -79,6 +87,21 @@ class MeetingViewSet(viewsets.ModelViewSet):
meeting.ended_at = timezone.now() meeting.ended_at = timezone.now()
meeting.save() meeting.save()
# 触发通知主持龙虾生成纪要
try:
from .minutes_api import MeetingEndNotifyView
from django.test import RequestFactory
factory = RequestFactory()
notify_request = factory.post(f'/api/v1/meetings/{meeting.id}/end-notify/')
response = MeetingEndNotifyView.as_view()(notify_request, pk=str(meeting.id))
if response.status_code == 200:
pass # 通知成功
except Exception as e:
# 通知失败不影响会议结束
pass
return Response({'status': '会议已结束'}) return Response({'status': '会议已结束'})
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
测试主持龙虾生成纪要完整流程
"""
import requests
API_BASE = 'http://localhost:8000/api/v1'
def test_host_agent_minutes():
print("="*60)
print("🦞 测试主持龙虾生成纪要流程")
print("="*60)
# 1. 登录
res = requests.post(f'{API_BASE}/auth/login/', json={
'username': 'test',
'password': 'test123'
})
token = res.json()['token']
headers = {'Authorization': f'Bearer {token}'}
print(f"✅ 登录成功")
# 2. 注册实例(模拟 OpenClaw
print("\n📝 注册实例...")
res = requests.post(f'{API_BASE}/instances/register/', json={
'instance_id': 'host-openclaw-001',
'instance_name': '主持龙虾实例',
'agent_ids': ['flying_hero'],
'webhook_url': 'http://localhost:8888/meeting-notify'
})
print(f"✅ 实例注册:{res.json()}")
# 3. 创建会议(指定主持龙虾)
print("\n🏛️ 创建会议(指定主持龙虾)...")
res = requests.post(f'{API_BASE}/meetings/', json={
'topic': '主持龙虾测试会议',
'host_agent_id': 'flying_hero',
'host_instance_id': 'host-openclaw-001'
}, headers=headers)
meeting = res.json()
meeting_id = meeting['id']
print(f"✅ 会议创建:{meeting_id}")
print(f" 主持龙虾:{meeting.get('host_agent_id')}")
# 4. 发送消息(模拟会议讨论)
print("\n💬 发送会议消息...")
messages = [
"大家好,开始今天的会议!",
"我来汇报一下 Q2 的进度。",
"这个项目需要更多资源支持。",
"好的,我会跟进这件事。",
"那我们下周再开会讨论细节。"
]
for msg in messages:
requests.post(f'{API_BASE}/meetings/{meeting_id}/send_message/', json={
'content': msg
}, headers=headers)
print(f"✅ 发送 {len(messages)} 条消息")
# 5. 结束会议(自动通知主持龙虾)
print("\n⏹️ 结束会议...")
res = requests.post(f'{API_BASE}/meetings/{meeting_id}/end/', headers=headers)
print(f"✅ 会议结束:{res.json()}")
# 6. 主持龙虾获取会议记录
print("\n📋 主持龙虾获取会议记录...")
res = requests.get(f'{API_BASE}/meetings/{meeting_id}/records/?agent_id=flying_hero')
if res.status_code == 200:
records = res.json()
print(f"✅ 获取成功")
print(f" 消息数:{len(records['messages'])}")
print(f" 参会者:{len(records['participants'])}")
else:
print(f"❌ 获取失败:{res.json()}")
return False
# 7. 主持龙虾生成纪要(模拟 AI 生成)
print("\n🤖 主持龙虾生成纪要...")
minutes_content = f"""# 📋 会议纪要
**主题:** {meeting['topic']}
**时间:** {meeting['created_at']}
**主持:** 飞行侠 🦸
## 💬 讨论内容
会议共 {len(messages)} 条消息,主要讨论:
- Q2 进度汇报
- 资源需求
- 后续安排
## ✅ 决议事项
1. 跟进资源支持事宜
2. 下周继续开会讨论细节
---
*生成时间:现在*
"""
# 8. 上传纪要
print("\n📤 上传会议纪要...")
res = requests.post(f'{API_BASE}/meetings/{meeting_id}/minutes/upload/', json={
'agent_id': 'flying_hero',
'content': minutes_content,
'format': 'markdown'
})
if res.status_code == 200:
print(f"✅ 纪要上传成功:{res.json()}")
else:
print(f"❌ 上传失败:{res.json()}")
return False
# 9. 验证会议状态
print("\n📊 验证会议状态...")
res = requests.get(f'{API_BASE}/meetings/{meeting_id}/', headers=headers)
meeting = res.json()
print(f" 状态:{meeting['status']}")
print(f" 纪要已生成:{meeting.get('minutes_generated', False)}")
print(f" 上传时间:{meeting.get('minutes_uploaded_at')}")
# 10. 获取纪要(平台留存)
print("\n📥 下载会议纪要...")
res = requests.get(f'{API_BASE}/meetings/{meeting_id}/minutes/?output=markdown', headers=headers)
if res.status_code == 200:
minutes = res.json()
print(f"✅ 获取成功")
print(f" 内容预览:{minutes['markdown'][:100]}...")
else:
print(f"❌ 获取失败:{res.json()}")
return False
print("\n" + "="*60)
print("✅ 主持龙虾生成纪要流程测试通过!")
print("="*60)
print("\n📊 流程总结:")
print("1. 用户创建会议 → 指定主持龙虾")
print("2. 会议进行 → 消息中央路由")
print("3. 会议结束 → 通知主持龙虾")
print("4. 主持龙虾 → 获取记录 + 生成纪要")
print("5. 上传纪要 → 平台留存供下载")
print("\n💡 算力分配:")
print("- 中央平台:消息路由 + 数据存储(轻量级)")
print("- 主持龙虾:生成纪要(消耗用户算力)")
return True
if __name__ == '__main__':
test_host_agent_minutes()