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}"