Files
2026-09-11 19:11:27 +08:00

398 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}"