398 lines
18 KiB
Python
398 lines
18 KiB
Python
from django.db import models
|
||
|
||
from apps.common.models import TeamOwnedModel, TimeStampedModel
|
||
|
||
|
||
class ModelProvider(TimeStampedModel):
|
||
class Status(models.TextChoices):
|
||
ACTIVE = "active", "Active"
|
||
DISABLED = "disabled", "Disabled"
|
||
|
||
name = models.CharField(max_length=64, unique=True)
|
||
display_name = models.CharField(max_length=128)
|
||
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
|
||
base_url = models.URLField(blank=True)
|
||
# 站级 API Key(中转站)。可插拔:换站 = 改这一行的 base_url + api_key,零改代码。
|
||
# 留空则 services 层按 provider.name 回退 settings.PROVIDER_KEYS(.env),避免密钥写死/进库。
|
||
api_key = models.CharField(max_length=255, blank=True)
|
||
metadata = models.JSONField(default=dict, blank=True)
|
||
|
||
def __str__(self) -> str:
|
||
return self.display_name
|
||
|
||
|
||
class ModelConfig(TimeStampedModel):
|
||
class Capability(models.TextChoices):
|
||
TEXT = "text", "Text"
|
||
IMAGE = "image", "Image"
|
||
VIDEO = "video", "Video"
|
||
VISION = "vision", "Vision"
|
||
AUDIO = "audio", "Audio"
|
||
EXPORT = "export", "Export"
|
||
|
||
class Status(models.TextChoices):
|
||
ACTIVE = "active", "Active"
|
||
DISABLED = "disabled", "Disabled"
|
||
|
||
provider = models.ForeignKey(ModelProvider, on_delete=models.CASCADE, related_name="models")
|
||
name = models.CharField(max_length=128)
|
||
display_name = models.CharField(max_length=128)
|
||
capability = models.CharField(max_length=32, choices=Capability.choices)
|
||
endpoint = models.CharField(max_length=255, blank=True)
|
||
unit_price = models.DecimalField(max_digits=12, decimal_places=4, default=0)
|
||
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
|
||
rate_limit_per_minute = models.PositiveIntegerField(default=60)
|
||
# 平台超管显式钦定的「该能力默认模型」。get_default_model 优先取它,未设则回落最早 active(零回归)。
|
||
is_default = models.BooleanField(default=False)
|
||
metadata = models.JSONField(default=dict, blank=True)
|
||
|
||
class Meta:
|
||
unique_together = [("provider", "name", "capability")]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.provider.name}:{self.name}:{self.capability}"
|
||
|
||
|
||
class ImageConversation(TeamOwnedModel):
|
||
"""图片创作工作室的「对话」实体:一条对话 = 一个生图会话线程,把多次生成(AITask)串起来。
|
||
左栏会话列表、切换、重命名、历史都基于本表。删除走软删(is_deleted),不连带删图——
|
||
成图仍在资产库里。mode 区分图片创作 / 模特上身图 / 平台套图三种工作台。"""
|
||
|
||
class Mode(models.TextChoices):
|
||
IMAGE = "image", "图片创作"
|
||
MODEL = "model", "模特上身图"
|
||
COVER = "cover", "平台套图"
|
||
|
||
title = models.CharField(max_length=120, default="默认创作")
|
||
mode = models.CharField(max_length=16, choices=Mode.choices, default=Mode.IMAGE)
|
||
product = models.ForeignKey(
|
||
"products.Product",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="image_conversations",
|
||
)
|
||
is_deleted = models.BooleanField(default=False)
|
||
purged_at = models.DateTimeField(null=True, blank=True)
|
||
# 每次在该对话里发起生成都刷新,左栏「最近」按它倒序
|
||
last_active_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
indexes = [
|
||
models.Index(fields=["team", "mode", "-last_active_at"]),
|
||
models.Index(fields=["team", "is_deleted", "purged_at"]),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"conv:{self.mode}:{self.title}"
|
||
|
||
|
||
class AITask(TeamOwnedModel):
|
||
class Type(models.TextChoices):
|
||
SCRIPT_GENERATION = "script_generation", "Script Generation"
|
||
SCRIPT_OPTIMIZATION = "script_optimization", "Script Optimization"
|
||
ENTITY_EXTRACTION = "entity_extraction", "Entity Extraction"
|
||
VIDEO_DIGEST = "video_digest", "Video Digest" # 上传视频提炼:参考视频 → 分镜稿
|
||
PRODUCT_IMAGE = "product_image", "Product Image"
|
||
PERSON_IMAGE = "person_image", "Person Image"
|
||
MODEL_TRIVIEW = "model_triview", "Model Triview"
|
||
SCENE_IMAGE = "scene_image", "Scene Image"
|
||
STORYBOARD = "storyboard", "Storyboard"
|
||
VIDEO_SEGMENT = "video_segment", "Video Segment"
|
||
VOICEOVER = "voiceover", "Voiceover"
|
||
EXPORT = "export", "Export"
|
||
# 自由创作(不绑 project 的独立视频生成,universal 全能参考 / keyframe 首尾帧)
|
||
FREE_VIDEO = "free_video", "Free Video"
|
||
|
||
class Status(models.TextChoices):
|
||
CREATED = "created", "Created"
|
||
RESERVED = "reserved", "Reserved"
|
||
SUBMITTED = "submitted", "Submitted"
|
||
POLLING = "polling", "Polling"
|
||
POSTPROCESSING = "postprocessing", "Postprocessing"
|
||
SUCCEEDED = "succeeded", "Succeeded"
|
||
FAILED = "failed", "Failed"
|
||
COMPENSATING = "compensating", "Compensating"
|
||
CANCELLED = "cancelled", "Cancelled"
|
||
|
||
project = models.ForeignKey(
|
||
"projects.Project",
|
||
on_delete=models.CASCADE,
|
||
null=True,
|
||
blank=True,
|
||
related_name="ai_tasks",
|
||
)
|
||
# 图片创作工作室的对话归属(独立生图任务才挂;流水线内部任务为空)。删对话不删任务。
|
||
conversation = models.ForeignKey(
|
||
ImageConversation,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="tasks",
|
||
)
|
||
task_type = models.CharField(max_length=48, choices=Type.choices)
|
||
status = models.CharField(max_length=32, choices=Status.choices, default=Status.CREATED)
|
||
model_config = models.ForeignKey(ModelConfig, on_delete=models.PROTECT, related_name="tasks")
|
||
idempotency_key = models.CharField(max_length=128, unique=True)
|
||
provider_task_id = models.CharField(max_length=255, blank=True)
|
||
request_payload = models.JSONField(default=dict, blank=True)
|
||
response_payload = models.JSONField(default=dict, blank=True)
|
||
# estimated/actual_cost:用户侧计价,单位**积分**(积分制重构后;历史行已 rescale ×10)
|
||
estimated_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
|
||
actual_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
|
||
# 平台真实成本,单位**人民币**(供应商结算口径)。0 = 成本未知(历史任务/未配置成本的模型)。
|
||
# 毛利 = actual_cost/points_per_yuan − base_cost;adminpanel 与 audit I9 消费。
|
||
base_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
|
||
error_code = models.CharField(max_length=64, blank=True)
|
||
error_message = models.TextField(blank=True)
|
||
submitted_at = models.DateTimeField(null=True, blank=True)
|
||
completed_at = models.DateTimeField(null=True, blank=True)
|
||
# YYX#row22:用户「已读」时间。null = 未读 → 用于导航栏「图片生成」未读数字胶囊
|
||
# 与每个商品预览右下角的未读分数。团队级共享(一人看过即全团队已读)。
|
||
read_at = models.DateTimeField(null=True, blank=True)
|
||
# 自由创作任务流的收藏 / 软删(其它任务类型恒 False,无行为影响)
|
||
is_favorited = models.BooleanField(default=False)
|
||
is_deleted = models.BooleanField(default=False)
|
||
purged_at = models.DateTimeField(null=True, blank=True)
|
||
|
||
class Meta:
|
||
indexes = [
|
||
models.Index(fields=["team", "status"]),
|
||
models.Index(fields=["project", "task_type"]),
|
||
models.Index(fields=["provider_task_id"]),
|
||
# 任务历史默认按团队 + 创建时间倒序(AI 工具页 / asset-factory)
|
||
models.Index(fields=["team", "-created_at"]),
|
||
# 按对话拉历史(图片创作工作室切换会话时回填批次)
|
||
models.Index(fields=["conversation", "created_at"]),
|
||
# YYX#row22:按团队 + 已读状态聚合未读数(导航/商品角标)
|
||
models.Index(fields=["team", "read_at"]),
|
||
# 自由创作任务流:按团队 + 类型倒序分页
|
||
models.Index(fields=["team", "task_type", "-created_at"]),
|
||
models.Index(fields=["team", "is_deleted", "purged_at"]),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.task_type}:{self.status}:{self.id}"
|
||
|
||
|
||
class AIModelAttempt(TimeStampedModel):
|
||
"""AITask 下的一次真实模型请求审计;不参与积分预留、扣费或任务生命周期。"""
|
||
|
||
class Status(models.TextChoices):
|
||
STARTED = "started", "Started"
|
||
SUCCEEDED = "succeeded", "Succeeded"
|
||
FAILED = "failed", "Failed"
|
||
|
||
task = models.ForeignKey(AITask, on_delete=models.CASCADE, related_name="model_attempts")
|
||
sequence = models.PositiveIntegerField()
|
||
provider = models.ForeignKey(ModelProvider, on_delete=models.SET_NULL, null=True, blank=True)
|
||
model_config = models.ForeignKey(ModelConfig, on_delete=models.SET_NULL, null=True, blank=True)
|
||
# 名称均保存历史快照,后续后台改名或删除配置也不影响旧任务审计。
|
||
provider_name = models.CharField(max_length=128)
|
||
provider_display_name = models.CharField(max_length=128, blank=True)
|
||
model_name = models.CharField(max_length=128)
|
||
model_display_name = models.CharField(max_length=128, blank=True)
|
||
public_model_name = models.CharField(max_length=128, blank=True)
|
||
capability = models.CharField(max_length=32)
|
||
operation = models.CharField(max_length=64)
|
||
status = models.CharField(max_length=24, choices=Status.choices, default=Status.STARTED)
|
||
is_retry = models.BooleanField(default=False)
|
||
is_fallback = models.BooleanField(default=False)
|
||
previous_attempt = models.ForeignKey(
|
||
"self",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="next_attempts",
|
||
)
|
||
provider_task_id = models.CharField(max_length=255, blank=True)
|
||
started_at = models.DateTimeField()
|
||
finished_at = models.DateTimeField(null=True, blank=True)
|
||
duration_ms = models.PositiveBigIntegerField(null=True, blank=True)
|
||
error_type = models.CharField(max_length=64, blank=True)
|
||
provider_error_code = models.CharField(max_length=128, blank=True)
|
||
raw_error = models.TextField(blank=True)
|
||
safe_error_summary = models.TextField(blank=True)
|
||
usage = models.JSONField(default=dict, blank=True)
|
||
platform_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
|
||
request_summary = models.JSONField(default=dict, blank=True)
|
||
response_summary = models.JSONField(default=dict, blank=True)
|
||
|
||
class Meta:
|
||
ordering = ["sequence"]
|
||
constraints = [
|
||
models.UniqueConstraint(fields=["task", "sequence"], name="ai_attempt_task_sequence_unique"),
|
||
]
|
||
indexes = [models.Index(fields=["task", "status"], name="ai_attempt_task_status_idx")]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.task_id}:{self.sequence}:{self.status}"
|
||
|
||
|
||
class QualityWord(TimeStampedModel):
|
||
"""平台单层质量词配置:按生成阶段(stage)+ 槽位(slot)挂若干质量词,
|
||
生成侧拼提示词时优先读取本配置;**无配置则回落各 builder 的写死值**(零回归)。
|
||
全平台共用一套(不挂 team),团队级覆盖以后再说。"""
|
||
|
||
class Stage(models.TextChoices):
|
||
PERSON = "person", "人物立绘"
|
||
MODEL_TRYON = "model_tryon", "模特上身图"
|
||
STORYBOARD = "storyboard", "故事板"
|
||
VIDEO = "video", "视频"
|
||
|
||
stage = models.CharField(max_length=24, choices=Stage.choices)
|
||
slot = models.CharField(max_length=24, default="quality")
|
||
text = models.CharField(max_length=200)
|
||
sort = models.IntegerField(default=0)
|
||
enabled = models.BooleanField(default=True)
|
||
|
||
class Meta:
|
||
ordering = ["stage", "sort", "created_at"]
|
||
indexes = [models.Index(fields=["stage", "slot"])]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.stage}/{self.slot}/{self.text}"
|
||
|
||
|
||
class PromptTemplate(TimeStampedModel):
|
||
"""平台可编辑的「生图/视频提示词模板」(视频项目生产线 6 条)。后台超管在 admin 改正文,
|
||
生成侧据 key 读取本表;留空/停用 → 回落各 builder 的写死默认(零回归)。
|
||
正文里 {占位符}(如 {商品}/{描述}/{脚本})运行时替换;未知占位符原样保留,改错不致命。
|
||
ratio:成品比例(空=按写死默认),gpt-image 仅 1:1 / portrait(竖) / landscape(横)三种。"""
|
||
|
||
class Key(models.TextChoices):
|
||
PERSON_PORTRAIT = "person_portrait", "人物立绘"
|
||
PERSON_TRIVIEW = "person_triview", "人物三视图"
|
||
PRODUCT_TRIVIEW = "product_triview", "商品三视图"
|
||
SCENE = "scene", "场景图"
|
||
STORYBOARD_FRAME = "storyboard_frame", "分镜图"
|
||
VIDEO_SEGMENT = "video_segment", "视频"
|
||
|
||
key = models.CharField(max_length=32, choices=Key.choices, unique=True)
|
||
template = models.TextField(blank=True, default="")
|
||
ratio = models.CharField(max_length=12, blank=True, default="") # "" / "1:1" / "portrait" / "landscape"
|
||
enabled = models.BooleanField(default=True)
|
||
|
||
class Meta:
|
||
ordering = ["key"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"prompt:{self.key}"
|
||
|
||
|
||
class CreationConversation(TeamOwnedModel):
|
||
"""全能创作的会话。一条会话 = 一次完整创作(可多轮改稿、多次出图/出片)。
|
||
|
||
和 ImageConversation 的区别:那张表只是「生图线程」,没有消息实体,历史靠 AITask 拼;
|
||
这里是真对话,消息落 CreationMessage。两者并存,互不影响(图片工作台仍走旧表)。
|
||
|
||
mode 发起时定死,会话内不可切(设计稿顶栏的模型/分辨率/比例跟着 mode 固定)。
|
||
pinned_refs 是「实体锁定」:本会话引用过的商品/角色/场景,每轮无条件带进上下文 ——
|
||
这是多次生成之间锁脸、锁商品的唯一手段,别省。
|
||
"""
|
||
|
||
class Mode(models.TextChoices):
|
||
VIDEO = "video", "视频创作"
|
||
IMAGE = "image", "图片创作"
|
||
|
||
class Status(models.TextChoices):
|
||
RUNNING = "running", "进行中"
|
||
COMPLETED = "completed", "已完成"
|
||
FAILED = "failed", "失败"
|
||
|
||
class AgentStatus(models.TextChoices):
|
||
IDLE = "idle", "空闲"
|
||
PLANNING = "planning", "整理方案中"
|
||
AWAITING_USER = "awaiting_user", "等待用户"
|
||
|
||
title = models.CharField(max_length=120, default="未命名创作")
|
||
mode = models.CharField(max_length=16, choices=Mode.choices, default=Mode.VIDEO)
|
||
preset = models.CharField(max_length=64, blank=True, default="") # "" = 自由创作
|
||
# 会话级参数:{model, resolution, ratio, duration} —— 设计稿顶栏 meta 就渲染它
|
||
params = models.JSONField(default=dict, blank=True)
|
||
# 实体锁定:[Ref],见契约 §1。每轮无条件带上
|
||
pinned_refs = models.JSONField(default=list, blank=True)
|
||
# 记忆:{summary, artifacts:[{msg_id,asset_id,prompt,kind}], turn_count}
|
||
memory = models.JSONField(default=dict, blank=True)
|
||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.RUNNING)
|
||
# Agent 编排态(与 status=会话生命周期正交)。planning 时整团队只能有一个在途 turn。
|
||
agent_status = models.CharField(
|
||
max_length=16, choices=AgentStatus.choices, default=AgentStatus.IDLE,
|
||
)
|
||
agent_started_at = models.DateTimeField(null=True, blank=True)
|
||
last_active_at = models.DateTimeField(auto_now_add=True)
|
||
is_deleted = models.BooleanField(default=False)
|
||
purged_at = models.DateTimeField(null=True, blank=True)
|
||
|
||
class Meta:
|
||
indexes = [
|
||
# 创作历史页:按团队 + 最近活跃倒序
|
||
models.Index(fields=["team", "-last_active_at"]),
|
||
models.Index(fields=["team", "status", "-last_active_at"]),
|
||
models.Index(fields=["team", "is_deleted", "purged_at"]),
|
||
models.Index(fields=["team", "agent_status"]),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"creation:{self.mode}:{self.title}"
|
||
|
||
|
||
class CreationMessage(TimeStampedModel):
|
||
"""全能创作对话流里的一条消息。kind 决定前端渲染成哪种卡片(见契约 §2)。
|
||
|
||
text 只给 TEXT 用;其余 kind 的内容全在 payload 里 —— 前端按 kind 走不同组件,
|
||
别把结构化内容塞进 text 再让前端解析。
|
||
|
||
生成类消息(GENERATING / RESULT)挂 task:提交时先落一条 GENERATING,
|
||
轮询到终态后**原地改成 RESULT**(不新增消息),这样对话流不会被中间态刷屏。
|
||
"""
|
||
|
||
class Role(models.TextChoices):
|
||
USER = "user", "用户"
|
||
ASSISTANT = "assistant", "AI"
|
||
SYSTEM = "system", "系统"
|
||
|
||
class Kind(models.TextChoices):
|
||
TEXT = "text", "文字气泡"
|
||
ELICIT = "elicit", "追问卡" # AI 反问用户,带 单选/多选/填空/选素材 控件
|
||
STRATEGY = "strategy", "创作策略理解卡"
|
||
PLAN = "plan", "视频最终方案卡"
|
||
PROMPT_FILE = "prompt_file", "生成 Prompt 文件卡"
|
||
CONFIRM = "confirm", "确认闸门(带预计积分)"
|
||
GENERATING = "generating", "生成中"
|
||
RESULT = "result", "生成结果"
|
||
ERROR = "error", "错误"
|
||
|
||
conversation = models.ForeignKey(
|
||
CreationConversation, on_delete=models.CASCADE, related_name="messages"
|
||
)
|
||
role = models.CharField(max_length=16, choices=Role.choices)
|
||
kind = models.CharField(max_length=24, choices=Kind.choices, default=Kind.TEXT)
|
||
text = models.TextField(blank=True, default="")
|
||
payload = models.JSONField(default=dict, blank=True)
|
||
# 本条消息引用的实体:[Ref]。用 id 取事实与参考图,不许只存 "@商品名" 字符串
|
||
refs = models.JSONField(default=list, blank=True)
|
||
task = models.ForeignKey(
|
||
AITask,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="creation_messages",
|
||
)
|
||
# 会话内自增渲染序;并发插入靠 select_for_update 取 max+1
|
||
seq = models.PositiveIntegerField(default=0)
|
||
|
||
class Meta:
|
||
ordering = ["seq", "created_at"]
|
||
indexes = [
|
||
models.Index(fields=["conversation", "seq"]),
|
||
]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["conversation", "seq"], name="uniq_creation_message_seq"
|
||
),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"msg:{self.kind}:{self.seq}"
|